terminal user interface to jujutsu. Focused on speed and clarity
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

graph renderer

+5080 -85
+6
.sisyphus/boulder.json
··· 1 + { 2 + "active_plan": "/home/eli/Code/ocaml/jj_tui/.sisyphus/plans/integrate-graph-renderer.md", 3 + "started_at": "2026-01-14T18:40:08.601Z", 4 + "session_ids": ["ses_44262dd50ffepdHiSiepGRcIDD"], 5 + "plan_name": "integrate-graph-renderer" 6 + }
+407
.sisyphus/notepads/integrate-graph-renderer/learnings.md
··· 1 + # Learnings - Task 2.1: Extend node type 2 + 3 + ## Task Completed: 2026-01-15 4 + 5 + ### What Was Done 6 + Extended the `node` type in `render_jj_graph.ml` with 8 new fields to support richer display information and preview functionality. 7 + 8 + ### Files Modified 9 + 1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph.ml` - Extended node type definition 10 + 2. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph_tests.ml` - Updated 14 node creation sites in tests 11 + 3. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/jj_json.ml` - Updated commits_to_nodes to populate new fields 12 + 13 + ### New Fields Added 14 + ```ocaml 15 + ; description : string 16 + ; bookmarks : string list 17 + ; author_email : string 18 + ; author_timestamp : string 19 + ; empty : bool 20 + ; hidden : bool 21 + ; divergent : bool 22 + ; is_preview : bool 23 + ``` 24 + 25 + ### Key Patterns Observed 26 + 27 + #### 1. Type Extension Strategy 28 + - Extended the record type first 29 + - Let the compiler identify all creation sites that need updating 30 + - Updated each site systematically 31 + - Ran `dune build` frequently to catch errors early 32 + 33 + #### 2. Test Data Defaults 34 + For test nodes, used sensible defaults: 35 + - `description = "test commit"` 36 + - `bookmarks = []` 37 + - `author_email = "test@example.com"` 38 + - `author_timestamp = "2024-01-01T00:00:00Z"` 39 + - `empty = false` 40 + - `hidden = false` 41 + - `divergent = false` 42 + - `is_preview = false` 43 + 44 + #### 3. Real Data Population (jj_json.ml) 45 + Mapped from jj_commit fields: 46 + - `description = jj_commit.description` 47 + - `bookmarks = jj_commit.bookmarks` 48 + - `author_email = jj_commit.author.email` 49 + - `author_timestamp = jj_commit.author.timestamp` 50 + - `empty = jj_commit.empty` 51 + - `hidden = jj_commit.hidden` 52 + - `divergent = jj_commit.divergent` 53 + - `is_preview = false` (always false for real commits, will be true for preview nodes) 54 + 55 + #### 4. LSP Behavior 56 + - LSP showed errors during incremental updates (expected) 57 + - Errors cleared after running `dune build` 58 + - Final LSP diagnostics were clean after all changes 59 + 60 + ### Verification Results 61 + ✅ `dune build` - SUCCESS (only warnings, no errors) 62 + ✅ `dune runtest` - SUCCESS (all tests pass) 63 + ✅ LSP diagnostics - CLEAN (no errors in modified files) 64 + 65 + ### Impact on Existing Code 66 + - No changes to graph rendering logic 67 + - No changes to test expectations (graph output unchanged) 68 + - All existing tests continue to pass 69 + - Type extension is backward compatible (only adds fields) 70 + 71 + ### Next Steps 72 + This completes Task 2.1. The node type now has all fields needed for: 73 + - Display functionality (description, bookmarks, author info) 74 + - Preview support (is_preview flag) 75 + - Additional metadata (empty, hidden, divergent) 76 + 77 + Ready for Task 2.2: Update graph rendering to use new fields. 78 + 79 + --- 80 + 81 + # Learnings - Task 2.2: Add elided revision support 82 + 83 + ## Task Completed: 2026-01-15 84 + 85 + ### What Was Done 86 + Added infrastructure for elided revisions - special nodes representing skipped commits in the graph display (shown as `~` in jj's output). 87 + 88 + ### Files Modified 89 + 1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph.ml` - Added elided node functions 90 + 2. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph_tests.ml` - Added 3 tests for elided nodes 91 + 92 + ### Functions Added 93 + ```ocaml 94 + (** Special marker for elided nodes *) 95 + let elided_marker = "~ELIDED~" 96 + 97 + (** Create a special node representing an elided section *) 98 + let make_elided_node () : node 99 + 100 + (** Check if a node represents an elided section *) 101 + let is_elided (n : node) : bool 102 + ``` 103 + 104 + ### Implementation Details 105 + 106 + #### Elided Node Marker 107 + - Used special string `"~ELIDED~"` as marker in both `commit_id` and `change_id` fields 108 + - This makes elided nodes easily identifiable and prevents confusion with real commits 109 + - Marker is unlikely to collide with actual commit IDs 110 + 111 + #### Elided Node Properties 112 + - `parents = []` - Elided nodes don't track parent relationships 113 + - `creation_time = Int64.zero` - No meaningful timestamp 114 + - `working_copy = false`, `immutable = false`, `wip = false` - Not a real commit 115 + - `commit_id = elided_marker`, `change_id = elided_marker` - Special marker 116 + - `description = "(elided revisions)"` - Human-readable description 117 + - `bookmarks = []` - No bookmarks 118 + - `author_email = ""`, `author_timestamp = ""` - No author info 119 + - `empty = false` - Not technically empty 120 + - `hidden = true` - Conceptually hidden (elided means skipped) 121 + - `divergent = false`, `is_preview = false` - Not divergent or preview 122 + 123 + #### Detection Function 124 + - `is_elided` checks if `commit_id` equals `elided_marker` 125 + - Simple and efficient O(1) check 126 + - Works because elided_marker is unique and won't appear in real commits 127 + 128 + ### Tests Added 129 + 1. **make_elided_node** - Verifies elided node creation with correct marker and properties 130 + 2. **is_elided_true** - Verifies `is_elided` returns true for elided nodes 131 + 3. **is_elided_false** - Verifies `is_elided` returns false for normal nodes 132 + 133 + ### Verification Results 134 + ✅ `dune build` - SUCCESS (only warnings, no errors) 135 + ✅ `dune runtest` - SUCCESS (all tests pass, including 3 new elided node tests) 136 + ✅ LSP diagnostics - CLEAN (no errors in modified files) 137 + 138 + ### Key Patterns Observed 139 + 140 + #### 1. Special Node Pattern 141 + - Used a special marker string to identify elided nodes 142 + - Alternative approaches considered: 143 + - Variant type: Would require changing node type everywhere 144 + - Optional field: Would add complexity to all node handling 145 + - Special marker: Simple, backward compatible, easy to check 146 + 147 + #### 2. Test Coverage 148 + - Tested creation (make_elided_node) 149 + - Tested positive detection (is_elided on elided node) 150 + - Tested negative detection (is_elided on normal node) 151 + - This covers all code paths for the new functions 152 + 153 + #### 3. No .mli File 154 + - `render_jj_graph.ml` has no corresponding `.mli` file 155 + - All functions are automatically exported 156 + - No need to update module signature 157 + 158 + ### Impact on Existing Code 159 + - No changes to existing functions or types 160 + - No changes to graph rendering logic (that's a later task) 161 + - All existing tests continue to pass 162 + - New functions are additive only 163 + 164 + ### Next Steps 165 + This completes Task 2.2. The infrastructure for elided nodes is now in place: 166 + - Functions to create elided nodes 167 + - Functions to identify elided nodes 168 + - Tests to verify behavior 169 + 170 + Ready for Task 2.3: Create structured output type for UI integration. 171 + 172 + ### Notes for Future Tasks 173 + - Elided nodes should render as `~` followed by blank line (per plan line 156-160) 174 + - Actual rendering logic will be implemented in a later task 175 + - The `hidden = true` property may be useful for filtering or special rendering 176 + 177 + --- 178 + 179 + # Learnings - Task 2.3: Create structured output types 180 + 181 + ## Task Completed: 2026-01-15 182 + 183 + ### What Was Done 184 + Added structured output types (`row_type` and `graph_row_output`) and implemented `render_nodes_structured` function to return structured data instead of plain strings for UI integration. 185 + 186 + ### Files Modified 187 + 1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph.ml` - Added types and function 188 + 2. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph_tests.ml` - Added 2 tests 189 + 190 + ### Types Added 191 + ```ocaml 192 + type row_type = 193 + | NodeRow (** The main row with the node glyph *) 194 + | LinkRow (** Merge/fork connector lines *) 195 + | PadRow (** Padding/continuation lines *) 196 + | TermRow (** Termination lines with ~ *) 197 + 198 + type graph_row_output = { 199 + graph_chars : string (** The graph prefix like "○ " or "├─╮" *) 200 + ; node : node (** The node this row represents *) 201 + ; row_type : row_type (** What kind of row this is *) 202 + } 203 + ``` 204 + 205 + ### Function Added 206 + ```ocaml 207 + val render_nodes_structured : 208 + ?info_lines:(node -> int) -> 209 + state -> 210 + node list -> 211 + graph_row_output list 212 + ``` 213 + 214 + ### Implementation Details 215 + 216 + #### Row Type Classification 217 + Created `classify_row_type` helper function that detects row type based on content: 218 + - **NodeRow**: Contains node glyphs (○, @, ◌, ◆) 219 + - **TermRow**: Contains termination marker (~) 220 + - **LinkRow**: Contains merge/fork characters (├, ╮, ╯, ╰, ┬, ┴, ┼) 221 + - **PadRow**: Everything else (vertical lines, spaces) 222 + 223 + Used `Str.search_forward` with `Str.regexp_string` to search for UTF-8 characters in strings, since `String.contains` only works with single-byte chars. 224 + 225 + #### Structured Rendering Function 226 + `render_nodes_structured` mirrors the logic of `render_nodes_to_string` but: 227 + 1. Builds a list of `graph_row_output` records instead of concatenating strings 228 + 2. Classifies each line using `classify_row_type` 229 + 3. Associates each line with its corresponding node 230 + 4. Returns the list in correct order (reversed at the end since we build it backwards) 231 + 232 + The function handles: 233 + - Extra pad lines from previous rows 234 + - Node lines (main commit row) 235 + - Link lines (merge/fork connectors) 236 + - Term lines (two lines for termination) 237 + - Info lines (additional content lines per node) 238 + - Final extra pad line 239 + 240 + ### Tests Added 241 + 1. **render_nodes_structured_simple** - Tests basic 2-node graph with info_lines 242 + - Verifies correct number of rows 243 + - Verifies each row has correct type, node, and graph_chars 244 + 245 + 2. **render_nodes_structured_row_types** - Tests complex merge graph 246 + - Verifies row type classification (NodeRow, LinkRow, PadRow) 247 + - Counts each type to ensure correct classification 248 + - Tests the merge topology from existing golden test 249 + 250 + ### Verification Results 251 + ✅ `dune build` - SUCCESS (only warnings, no errors) 252 + ✅ `dune runtest` - SUCCESS (all tests pass, including 2 new structured output tests) 253 + ✅ LSP diagnostics - CLEAN (no errors in modified files) 254 + 255 + ### Key Patterns Observed 256 + 257 + #### 1. UTF-8 String Searching 258 + OCaml's `String.contains` only works with single-byte characters. For UTF-8 glyphs, used: 259 + ```ocaml 260 + let contains_str s substr = 261 + try 262 + let _ = Str.search_forward (Str.regexp_string substr) s 0 in 263 + true 264 + with Not_found -> false 265 + ``` 266 + 267 + #### 2. Mirroring Existing Logic 268 + The `render_nodes_structured` function closely mirrors `render_nodes_to_string`: 269 + - Same structure and flow 270 + - Same handling of extra_pad_line_ref 271 + - Same rendering of node_line, link_line, term_line, pad_lines 272 + - Only difference: builds structured list instead of string buffer 273 + 274 + This ensures consistency and makes it easy to verify correctness by comparing outputs. 275 + 276 + #### 3. Backward Compatibility 277 + - Kept existing `render_nodes_to_string` function unchanged 278 + - New function is additive only 279 + - All existing tests continue to pass 280 + - No breaking changes to public API 281 + 282 + #### 4. Test Expectations 283 + - Initial test expectations needed adjustment (dune promote) 284 + - Final pad line is not included when empty (expected behavior) 285 + - Tests verify both structure (row count, types) and content (graph_chars) 286 + 287 + ### Impact on Existing Code 288 + - No changes to existing functions 289 + - No changes to graph rendering logic 290 + - All existing tests continue to pass 291 + - New types and function are additive only 292 + 293 + ### Next Steps 294 + This completes Task 2.3. The structured output infrastructure is now in place: 295 + - Types to represent different row types 296 + - Function to generate structured output 297 + - Tests to verify behavior 298 + 299 + Ready for Task 2.4: Update existing tests (if needed). 300 + 301 + ### Notes for Future Tasks 302 + - The `graph_row_output` type can be extended with additional fields if needed 303 + - The `classify_row_type` function could be made more sophisticated if needed 304 + - The UI can now consume structured output and style different row types differently 305 + - Each row is associated with its node, enabling hover/selection features 306 + 307 + --- 308 + 309 + # Learnings - Task 3.1: Add JSON-based graph fetching functions 310 + 311 + ## Task Completed: 2026-01-15 312 + 313 + ### What Was Done 314 + Added two new functions to `process_wrappers.ml` for JSON-based graph fetching using the jj_json module. 315 + 316 + ### Files Modified 317 + 1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/process_wrappers.ml` - Added get_graph_json and get_graph_nodes 318 + 319 + ### Functions Added 320 + ```ocaml 321 + (** Fetch graph data as JSON and parse into commits *) 322 + val get_graph_json : 323 + ?revset:string -> 324 + int -> (* limit *) 325 + Jj_json.jj_commit list 326 + 327 + (** Fetch and convert to renderer nodes *) 328 + val get_graph_nodes : 329 + ?revset:string -> 330 + int -> 331 + (Render_jj_graph.node list * string maybe_unique array) 332 + ``` 333 + 334 + ### Implementation Details 335 + 336 + #### get_graph_json Function 337 + Executes `jj log --no-graph --color never -T <json_template> --limit <n> [revset]` and parses the JSONL output: 338 + - Builds command args list with JSON template from `Jj_json.json_log_template` 339 + - Handles optional revset parameter by appending `-r <revset>` to args 340 + - Calls `jj_no_log` to execute the command (no snapshot needed for read-only operation) 341 + - Parses output using `Jj_json.parse_jj_log_output` 342 + - Returns list of `Jj_json.jj_commit` or fails with descriptive error message 343 + 344 + #### get_graph_nodes Function 345 + Converts commits to renderer nodes and extracts rev_ids for selection tracking: 346 + - Calls `get_graph_json` to fetch commits 347 + - Converts to nodes using `Jj_json.commits_to_nodes` 348 + - Extracts rev_ids as `string maybe_unique array`: 349 + - `Duplicate commit_id` if commit is divergent or hidden 350 + - `Unique change_id` otherwise 351 + - Returns tuple of `(node list, string maybe_unique array)` 352 + 353 + ### Key Patterns Observed 354 + 355 + #### 1. Command Execution Pattern 356 + Followed the same pattern as `graph_and_revs`: 357 + - Build args list incrementally 358 + - Handle optional revset with pattern matching 359 + - Use `jj_no_log` for execution (no snapshot for read-only commands) 360 + - No async/promise needed for simple synchronous operations 361 + 362 + #### 2. Rev_id Type Clarification 363 + The plan mentioned `Global_vars.rev_id array` but the actual type is `string maybe_unique array`: 364 + - `rev_id` is defined in `Process` module as a record with change_id, commit_id, divergent 365 + - The UI layer uses `string maybe_unique` where the string is either change_id or commit_id 366 + - `maybe_unique` is a variant: `Unique of 'a | Duplicate of 'a` 367 + - Divergent or hidden commits use `Duplicate commit_id` 368 + - Normal commits use `Unique change_id` 369 + 370 + This matches the pattern in the existing `find_selectable_from_graph` function (lines 80-82). 371 + 372 + #### 3. Error Handling 373 + Used simple `failwith` for JSON parsing errors since: 374 + - This is a critical error that should stop execution 375 + - The error message includes the parse error details 376 + - Matches the error handling pattern in the codebase 377 + - Alternative would be to return Result type, but that's not the pattern here 378 + 379 + #### 4. Functor Pattern 380 + Functions added inside the `Make` functor: 381 + - Have access to `jj_no_log` from the Process parameter 382 + - Follow the same structure as other functions in the module 383 + - No need to pass Process explicitly 384 + 385 + ### Verification Results 386 + ✅ `dune build` - SUCCESS (only warnings, no errors) 387 + ✅ LSP diagnostics - CLEAN (no errors in process_wrappers.ml) 388 + 389 + ### Impact on Existing Code 390 + - No changes to existing functions 391 + - `graph_and_revs` remains unchanged (kept for backward compatibility) 392 + - New functions are additive only 393 + - No breaking changes to public API 394 + 395 + ### Next Steps 396 + This completes Task 3.1. The JSON-based graph fetching functions are now in place: 397 + - `get_graph_json` fetches and parses JSON commits 398 + - `get_graph_nodes` converts to renderer nodes with rev_ids 399 + - Both functions ready for integration in graph view 400 + 401 + Ready for Phase 4: Graph View Integration. 402 + 403 + ### Notes for Future Tasks 404 + - The new functions use the same command execution pattern as existing code 405 + - Rev_ids are extracted in the same format as the old graph_and_revs function 406 + - The functions can be used as drop-in replacements once the UI is updated 407 + - Consider adding tests for these functions in a future task
+364
.sisyphus/plans/integrate-graph-renderer.md
··· 1 + # Plan: Integrate New Graph Renderer into jj_tui 2 + 3 + ## Overview 4 + 5 + Replace the current approach (parsing jj's ASCII graph output with regex) with the new OCaml-native graph renderer (`render_jj_graph.ml`). This enables: 6 + 1. Full control over graph rendering 7 + 2. Ability to insert "preview" nodes before/after any node for move/rebase previews 8 + 3. Cleaner separation between data fetching and rendering 9 + 10 + ## Architecture 11 + 12 + ``` 13 + Current Flow: 14 + jj log (ASCII graph + markers) → regex parse → display rows 15 + 16 + New Flow: 17 + jj log --no-graph --color never (JSON) → parse JSON → build node list → render_jj_graph → display 18 + 19 + [insert preview nodes here] 20 + ``` 21 + 22 + ## Design Decisions 23 + 24 + - **Node type**: Flat record with `is_preview` flag (not a variant) 25 + - **Styling**: Custom styling (to be decided later, not matching jj) 26 + - **Elided revisions**: Render as `~` with one-line gap, matching jj's format 27 + 28 + --- 29 + 30 + ## Phase 1: JSON Data Layer 31 + 32 + **Goal**: Create a module to fetch and parse jj log output as JSON. 33 + 34 + ### Task 1.1: Create `jj_tui/lib/jj_json.ml` 35 + 36 + New module for JSON types and parsing. 37 + 38 + **Types to define:** 39 + 40 + ```ocaml 41 + type jj_author = { 42 + email : string 43 + ; timestamp : string 44 + } 45 + [@@deriving yojson] 46 + 47 + type jj_commit = { 48 + commit_id : string 49 + ; parents : string list 50 + ; change_id : string 51 + ; description : string 52 + ; working_copy : bool 53 + ; immutable : bool 54 + ; wip : bool 55 + ; hidden : bool 56 + ; divergent : bool 57 + ; empty : bool 58 + ; bookmarks : string list 59 + ; author : jj_author 60 + } 61 + [@@deriving yojson] 62 + ``` 63 + 64 + **Functions to implement:** 65 + 66 + ```ocaml 67 + (** The jj template that produces JSONL output *) 68 + val json_log_template : string 69 + 70 + (** Parse JSONL (one JSON object per line) from jj log output *) 71 + val parse_jj_log_output : string -> (jj_commit list, string) result 72 + 73 + (** Convert list of jj_commit to render_jj_graph.node list. 74 + Uses two-pass approach: create nodes, then link parents. *) 75 + val commits_to_nodes : jj_commit list -> Render_jj_graph.node list 76 + ``` 77 + 78 + **JSON template string:** 79 + 80 + ``` 81 + '{' 82 + ++ '"commit_id":' ++ json(commit_id) 83 + ++ ',"parents":[' ++ parents.map(|c| json(c.commit_id())).join(",") ++ ']' 84 + ++ ',"change_id":' ++ json(change_id) 85 + ++ ',"description":' ++ json(description) 86 + ++ ',"working_copy":' ++ json(current_working_copy) 87 + ++ ',"immutable":' ++ json(immutable) 88 + ++ ',"wip":' ++ json(description.first_line().starts_with("wip:")) 89 + ++ ',"hidden":' ++ json(hidden) 90 + ++ ',"divergent":' ++ json(divergent) 91 + ++ ',"empty":' ++ json(empty) 92 + ++ ',"bookmarks":[' ++ bookmarks.map(|b| json(b.name())).join(",") ++ ']' 93 + ++ ',"author":{"email":' ++ json(author.email()) ++ ',"timestamp":' ++ json(author.timestamp()) ++ '}' 94 + ++ '} 95 + ' 96 + ``` 97 + 98 + ### Task 1.2: Update `jj_tui/lib/dune` 99 + 100 + Add `jj_json` module and ensure `yojson` dependency is available (already in project for tests). 101 + 102 + ### Task 1.3: Write tests for `jj_json.ml` 103 + 104 + Create `jj_tui/lib/jj_json_tests.ml` with: 105 + - Test parsing valid JSONL 106 + - Test handling missing parents gracefully (root commits) 107 + - Test node linking produces correct parent references 108 + 109 + --- 110 + 111 + ## Phase 2: Extend Graph Renderer 112 + 113 + **Goal**: Enhance `render_jj_graph.ml` to support richer output needed for UI integration. 114 + 115 + ### Task 2.1: Extend `node` type in `render_jj_graph.ml` 116 + 117 + Add fields needed for display and preview functionality: 118 + 119 + ```ocaml 120 + type node = { 121 + (* Existing fields *) 122 + parents : node list 123 + ; creation_time : int64 124 + ; working_copy : bool 125 + ; immutable : bool 126 + ; wip : bool 127 + ; change_id : string 128 + ; commit_id : string 129 + (* New fields for display *) 130 + ; description : string 131 + ; bookmarks : string list 132 + ; author_email : string 133 + ; author_timestamp : string 134 + ; empty : bool 135 + ; hidden : bool 136 + ; divergent : bool 137 + (* Preview support *) 138 + ; is_preview : bool 139 + } 140 + ``` 141 + 142 + **Note**: Update `jj_json.ml` conversion function to populate all fields. 143 + 144 + ### Task 2.2: Add elided revision support 145 + 146 + Add a way to represent elided sections in the graph: 147 + 148 + ```ocaml 149 + (** Special node type for elided revisions *) 150 + val make_elided_node : unit -> node 151 + 152 + (** Check if a node represents an elided section *) 153 + val is_elided : node -> bool 154 + ``` 155 + 156 + Elided nodes should render as: 157 + ``` 158 + ~ 159 + ``` 160 + (tilde followed by blank line, matching jj's format) 161 + 162 + ### Task 2.3: Create structured output type 163 + 164 + Instead of returning strings, return structured data for UI integration: 165 + 166 + ```ocaml 167 + type graph_row_output = { 168 + graph_chars : string (* The graph prefix like "○ " or "├─╮" *) 169 + ; node : node (* The node this row represents *) 170 + ; row_type : row_type (* What kind of row this is *) 171 + } 172 + 173 + and row_type = 174 + | NodeRow (* The main row with the node glyph *) 175 + | LinkRow (* Merge/fork connector lines *) 176 + | PadRow (* Padding/continuation lines *) 177 + | TermRow (* Termination lines with ~ *) 178 + 179 + (** Render nodes to structured output for UI integration *) 180 + val render_nodes_structured : 181 + state -> 182 + node list -> 183 + info_lines:(node -> int) -> (* How many content lines per node *) 184 + graph_row_output list 185 + ``` 186 + 187 + ### Task 2.4: Update existing tests 188 + 189 + Ensure all existing golden tests in `render_jj_graph_tests.ml` still pass after type changes. 190 + 191 + --- 192 + 193 + ## Phase 3: Process Layer Integration 194 + 195 + **Goal**: Add new functions to `process_wrappers.ml` for JSON-based graph fetching. 196 + 197 + ### Task 3.1: Add `get_graph_json` function 198 + 199 + ```ocaml 200 + (** Fetch graph data as JSON and parse into commits *) 201 + val get_graph_json : 202 + ?revset:string -> 203 + int -> (* limit *) 204 + Jj_json.jj_commit list 205 + 206 + (** Fetch and convert to renderer nodes *) 207 + val get_graph_nodes : 208 + ?revset:string -> 209 + int -> 210 + (Render_jj_graph.node list * Global_vars.rev_id array) 211 + ``` 212 + 213 + **Implementation notes:** 214 + - Call `jj log --no-graph --color never -T <json_template> --limit <n> [revset]` 215 + - Parse JSONL output 216 + - Convert to nodes 217 + - Extract rev_ids for selection tracking 218 + 219 + ### Task 3.2: Keep old `graph_and_revs` working 220 + 221 + Don't remove the old function yet - keep it for fallback/comparison during development. 222 + 223 + --- 224 + 225 + ## Phase 4: Graph View Integration 226 + 227 + **Goal**: Update `graph_view.ml` to use the new renderer. 228 + 229 + ### Task 4.1: Create `render_commit_content` function 230 + 231 + Render the text content for a node (styling TBD): 232 + 233 + ```ocaml 234 + val render_commit_content : Render_jj_graph.node -> Notty.image 235 + ``` 236 + 237 + Basic implementation (styling to be refined later): 238 + - Show change_id (first 8 chars) 239 + - Show author email 240 + - Show timestamp 241 + - Show description first line (or "(no description set)") 242 + - Show bookmarks if any 243 + - Different appearance for working_copy, immutable, empty, preview nodes 244 + 245 + ### Task 4.2: Create `render_graph_row` function 246 + 247 + Combine graph prefix with content: 248 + 249 + ```ocaml 250 + val render_graph_row : 251 + Render_jj_graph.graph_row_output -> 252 + render_content:(Render_jj_graph.node -> Notty.image) -> 253 + Notty.image 254 + ``` 255 + 256 + ### Task 4.3: Update `graph_view` function 257 + 258 + Replace the current flow: 259 + 260 + ```ocaml 261 + (* OLD *) 262 + let graph, rev_ids = graph_and_revs ?revset max_commits () in 263 + (* process graph which is array of `Selectable string | `Filler string *) 264 + 265 + (* NEW *) 266 + let nodes, rev_ids = get_graph_nodes ?revset max_commits in 267 + let rendered_rows = Render_jj_graph.render_nodes_structured ... nodes in 268 + (* Convert to list items, distinguishing Selectable from Filler based on row_type *) 269 + ``` 270 + 271 + ### Task 4.4: Handle elided revisions in UI 272 + 273 + Elided nodes should appear as `Filler` items (non-selectable) in the list widget. 274 + 275 + --- 276 + 277 + ## Phase 5: Preview Node Support 278 + 279 + **Goal**: Enable inserting preview nodes for rebase/move visualization. 280 + 281 + ### Task 5.1: Add preview insertion functions 282 + 283 + In `render_jj_graph.ml` or a new `graph_preview.ml`: 284 + 285 + ```ocaml 286 + (** Create a preview node *) 287 + val make_preview_node : 288 + label:string -> 289 + ?target_commit_id:string -> 290 + unit -> 291 + node 292 + 293 + (** Insert a preview node after the specified commit *) 294 + val insert_preview_after : 295 + nodes:node list -> 296 + after_commit_id:string -> 297 + preview:node -> 298 + node list 299 + 300 + (** Insert a preview node before the specified commit *) 301 + val insert_preview_before : 302 + nodes:node list -> 303 + before_commit_id:string -> 304 + preview:node -> 305 + node list 306 + ``` 307 + 308 + ### Task 5.2: Visual distinction for preview nodes 309 + 310 + In `render_commit_content`, handle `is_preview = true` nodes: 311 + - Use different glyph (e.g., `◇` or `?`) 312 + - Use distinct styling (e.g., dim, italic) 313 + - Show preview label instead of commit info 314 + 315 + ### Task 5.3: Integration with commands 316 + 317 + Wire up preview display for commands like: 318 + - Rebase preview: Show where commits would land 319 + - Move preview: Show destination 320 + 321 + (Specific command integration TBD based on UX decisions) 322 + 323 + --- 324 + 325 + ## Files Summary 326 + 327 + | File | Action | Description | 328 + |------|--------|-------------| 329 + | `lib/jj_json.ml` | **Create** | JSON types, parsing, node conversion | 330 + | `lib/jj_json_tests.ml` | **Create** | Unit tests for JSON parsing | 331 + | `lib/render_jj_graph.ml` | **Modify** | Extend node type, add structured output | 332 + | `lib/render_jj_graph_tests.ml` | **Modify** | Update tests for new node fields | 333 + | `lib/process_wrappers.ml` | **Modify** | Add JSON-based graph fetching | 334 + | `bin/graph_view.ml` | **Modify** | Use new renderer, add content rendering | 335 + | `lib/dune` | **Modify** | Add jj_json module | 336 + 337 + --- 338 + 339 + ## Implementation Order 340 + 341 + 1. **Phase 1**: Create `jj_json.ml` - can be tested independently 342 + 2. **Phase 2**: Extend `render_jj_graph.ml` - update types and add structured output 343 + 3. **Phase 3**: Update `process_wrappers.ml` - add JSON fetching 344 + 4. **Phase 4**: Update `graph_view.ml` - integrate everything 345 + 5. **Phase 5**: Add preview node support 346 + 347 + Each phase should be testable independently before moving to the next. 348 + 349 + --- 350 + 351 + ## Testing Strategy 352 + 353 + 1. **Unit tests**: JSON parsing, node conversion, graph rendering 354 + 2. **Golden tests**: Existing tests in `render_jj_graph_tests.ml` should continue passing 355 + 3. **Integration test**: Run the TUI and verify graph displays correctly 356 + 4. **Manual testing**: Compare output visually with `jj log` output 357 + 358 + --- 359 + 360 + ## Open Questions (for later) 361 + 362 + 1. **Styling**: What colors/styles to use for different node types? 363 + 2. **Preview UX**: How should preview nodes be triggered and displayed? 364 + 3. **Performance**: Is JSON parsing fast enough for large repos? (Probably fine with limits)
+301
.sisyphus/plans/jj-graph-renderer.md
··· 1 + # JJ Graph Renderer Implementation Plan 2 + 3 + **Created**: 2026-01-15 4 + **Status**: Ready for implementation 5 + 6 + --- 7 + 8 + ## Summary 9 + 10 + Implement a lane-based DAG graph renderer in OCaml that exactly matches the Rust reference implementation from Meta/Sapling. The renderer produces both string output (for tests) and Notty UI output (for TUI integration). 11 + 12 + --- 13 + 14 + ## Non-negotiables 15 + 16 + - **Do not change golden test outputs** in `jj_tui/lib/render_jj_graph_tests.ml` 17 + - **Preserve the public API that tests compile against**: 18 + - `type node = { parents : node list; creation_time : int64; working_copy : bool; immutable : bool; wip : bool; change_id : string; commit_id : string }` 19 + - `type state = { depth : int; columns : _ array; pending_joins : _ list }` 20 + - `render_nodes_to_string : ?info_rows:(node -> int) -> state -> node list -> string` 21 + 22 + --- 23 + 24 + ## Key Risk / Design Constraint 25 + 26 + The Rust reference renderer's box drawing output uses **2 chars per column cell** (`"│ "`, `"╭─"`, etc). The OCaml golden tests encode a **different spacing convention**. 27 + 28 + **Strategy**: 29 + - Port the **topology → intermediate representation** (columns + link flags) 1:1 from Rust 30 + - Implement OCaml box-drawing formatter that produces **exactly the OCaml golden output format** 31 + 32 + --- 33 + 34 + ## Architecture 35 + 36 + ``` 37 + ┌─────────────────────────────────────────────────────────────┐ 38 + │ API Layer │ 39 + │ render_nodes_to_string : state -> node list -> string │ 40 + │ render_nodes_to_ui : state -> node list -> Notty.image │ 41 + └─────────────────────────────────────────────────────────────┘ 42 + 43 + 44 + ┌─────────────────────────────────────────────────────────────┐ 45 + │ GraphRowRenderer │ 46 + │ - Maintains column state │ 47 + │ - Assigns nodes to columns │ 48 + │ - Produces GraphRow records │ 49 + └─────────────────────────────────────────────────────────────┘ 50 + 51 + 52 + ┌─────────────────────────────────────────────────────────────┐ 53 + │ BoxDrawingRenderer │ 54 + │ - Converts GraphRow → string/image │ 55 + │ - Selects glyphs based on LinkLine flags │ 56 + │ - Handles pad lines, term lines │ 57 + └─────────────────────────────────────────────────────────────┘ 58 + ``` 59 + 60 + --- 61 + 62 + ## Tasks 63 + 64 + ### Task 1: Lock down existing compilation surface 65 + 66 + **Goal**: Ensure tests compile unchanged 67 + 68 + **Actions**: 69 + - Leave `node` and `state` types in `render_jj_graph.ml` as tests expect 70 + - `state.depth`, `state.pending_joins` may become unused but must exist 71 + - `state.columns` may be repurposed as renderer's column state 72 + 73 + **Deliverable**: `render_jj_graph_tests.ml` compiles unchanged 74 + 75 + --- 76 + 77 + ### Task 2: Implement internal type equivalents 78 + 79 + **Goal**: Create OCaml versions of Rust types 80 + 81 + **Types to add** (can be nested modules in `render_jj_graph.ml`): 82 + 83 + ```ocaml 84 + (* Column state *) 85 + type 'a column = Empty | Blocked | Reserved of 'a | Ancestor of 'a | Parent of 'a 86 + 87 + (* Ancestor specification for parents *) 88 + type ancestor = AncestorOf of node | ParentOf of node | Anonymous 89 + (* Note: Anonymous treated as direct for is_direct() like Rust *) 90 + 91 + (* Row element types *) 92 + type node_line_entry = NL_Blank | NL_Ancestor | NL_Parent | NL_Node 93 + type pad_line_entry = PL_Blank | PL_Ancestor | PL_Parent 94 + 95 + (* LinkLine as int bitset *) 96 + module LinkLine : sig 97 + type t = int 98 + val empty : t 99 + val ( lor ) : t -> t -> t 100 + val intersects : t -> t -> bool 101 + 102 + (* Bit constants *) 103 + val horiz_parent : t (* 0x0001 *) 104 + val horiz_ancestor : t (* 0x0002 *) 105 + val vert_parent : t (* 0x0004 *) 106 + val vert_ancestor : t (* 0x0008 *) 107 + val left_fork_parent : t (* 0x0010 *) 108 + val left_fork_ancestor : t (* 0x0020 *) 109 + val right_fork_parent : t (* 0x0040 *) 110 + val right_fork_ancestor : t (* 0x0080 *) 111 + val left_merge_parent : t (* 0x0100 *) 112 + val left_merge_ancestor : t (* 0x0200 *) 113 + val right_merge_parent : t (* 0x0400 *) 114 + val right_merge_ancestor : t (* 0x0800 *) 115 + val child : t (* 0x1000 *) 116 + 117 + (* Compound flags *) 118 + val horizontal : t 119 + val vertical : t 120 + val left_fork : t 121 + val right_fork : t 122 + val left_merge : t 123 + val right_merge : t 124 + val any_merge : t 125 + val any_fork : t 126 + end 127 + 128 + (* Intermediate row representation *) 129 + type graph_row = { 130 + row_node : node 131 + ; glyph : Uchar.t 132 + ; message : string (* empty for now, ready for future text *) 133 + ; merge : bool 134 + ; node_line : node_line_entry array 135 + ; link_line : LinkLine.t array option 136 + ; term_line : bool array option 137 + ; pad_lines : pad_line_entry array 138 + } 139 + ``` 140 + 141 + **Deliverable**: Compiles, no formatting yet 142 + 143 + --- 144 + 145 + ### Task 3: Column utility functions 146 + 147 + **Goal**: Implement Rust `ColumnsExt` trait equivalent 148 + 149 + **Functions**: 150 + ```ocaml 151 + val column_matches : node column -> node -> bool 152 + val column_variant : _ column -> int (* for merge priority *) 153 + val column_merge : node column -> node column -> node column 154 + 155 + val columns_find : node column array -> node -> int option 156 + val columns_first_empty : node column array -> int option 157 + val columns_find_empty : node column array -> prefer:int -> int option 158 + val columns_new_empty : node column array ref -> int 159 + val columns_reset : node column array ref -> unit 160 + (* Blocked → Empty, trim trailing Empty *) 161 + ``` 162 + 163 + **Priority order for merge**: Parent(4) > Ancestor(3) > Reserved(2) > Blocked(1) > Empty(0) 164 + 165 + **Deliverable**: Unit correctness by inspection; used by renderer 166 + 167 + --- 168 + 169 + ### Task 4: GraphRowRenderer.next_row algorithm 170 + 171 + **Goal**: Translate Rust core algorithm to OCaml 172 + 173 + **Algorithm steps**: 174 + 175 + 1. **Determine target column for node**: 176 + - If column already reserved for it, use it 177 + - Else use first empty or append 178 + - Clear target to Empty before assigning parents 179 + 180 + 2. **Initialize row arrays from current columns**: 181 + - `node_line` from column state (Ancestor/Parent → vertical markers; others blank) 182 + - `link_line` similarly 183 + - `term_line` all false 184 + - `pad_lines` similarly 185 + 186 + 3. **Assign parent columns**: 187 + - If parent already has a column, merge into it 188 + - Else try `find_empty` preferring current node column 189 + - Else append new column and extend row arrays in sync 190 + 191 + 4. **Mark anonymous parents as terminations** (`term_line[i] = true`) 192 + 193 + 5. **Single-parent swap optimization**: 194 + - If exactly one parent and parent column > node column 195 + - Swap columns and emit fork/merge link flags 196 + 197 + 6. **Connect node column to all parent columns**: 198 + - Compute bounds (min/max ancestor/parent columns) 199 + - Fill horizontal segments between outer bounds 200 + - Set left/right merge markers on node col if needed 201 + - Set fork markers per parent column 202 + 203 + 7. **Reset columns** (Blocked cleanup + trailing trim) 204 + 205 + 8. **Filter optional lines** (only keep link_line/term_line if needed) 206 + 207 + **Deliverable**: Working topology engine producing stable row structures 208 + 209 + --- 210 + 211 + ### Task 5: BoxDrawing formatting (matching OCaml golden outputs) 212 + 213 + **Goal**: Convert GraphRow to string matching test expectations exactly 214 + 215 + **Glyph mapping** (use existing `P` module constants): 216 + - `P.v` = `│`, `P.h` = `─` 217 + - `P.vr` = `├`, `P.vl` = `┤`, `P.t` = `┬`, `P.b` = `┴`, `P.cross` = `┼` 218 + - Elbows: `P.edl` = `╭`, `P.edr` = `╮`, `P.eul` = `╰`, `P.eur` = `╯` 219 + - `P.ancestor` = `·`, `P.sp` = ` ` 220 + - Node glyphs: `P.Node.working_copy` = `@`, `P.Node.normal` = `○`, `P.Node.immutable` = `◆`, `P.Node.wip` = `◌` 221 + 222 + **Glyph selection logic** (port from Rust `box_drawing.rs`): 223 + - 14 glyph types: SPACE, HORIZONTAL, PARENT, ANCESTOR, MERGE_LEFT/RIGHT/BOTH, FORK_LEFT/RIGHT/BOTH, JOIN_LEFT/RIGHT/BOTH, TERMINATION 224 + - Complex conditional based on LinkLine flags and `merge` bool 225 + 226 + **Important**: Match OCaml golden test spacing, not Rust 2-wide cells 227 + 228 + **Also implement**: 229 + - Termination rendering: `│` then `~` rows 230 + - `info_rows` support: insert additional pad rows after certain nodes 231 + 232 + **Deliverable**: `render_nodes_to_string` matches all golden tests exactly 233 + 234 + --- 235 + 236 + ### Task 6: Notty UI output 237 + 238 + **Goal**: Provide `render_nodes_to_ui` for TUI integration 239 + 240 + **Approach (correctness-first)**: 241 + - First implementation: call `render_nodes_to_string`, convert to `Notty.image` via `I.string A.empty` with newline splitting 242 + - Later optimization (optional): render cell-wise with `I.uchar` and `I.hcat/I.vcat` 243 + 244 + **Signature**: 245 + ```ocaml 246 + val render_nodes_to_ui : ?info_rows:(node -> int) -> state -> node list -> Notty.image 247 + ``` 248 + 249 + **Deliverable**: UI renderer visually matching string output 250 + 251 + --- 252 + 253 + ### Task 7: Additional tests (additive only) 254 + 255 + **Goal**: Better coverage without modifying existing golden outputs 256 + 257 + **New test cases**: 258 + - Linear chain (no merges, only `│` lines) 259 + - Single-parent swap scenario 260 + - Explicit anonymous termination behavior (`~`) 261 + - Multi-parent/octopus merge patterns 262 + 263 + **Smoke test for UI**: 264 + - Ensure `render_nodes_to_ui` produces non-empty image for simple graph 265 + 266 + **Deliverable**: Improved test coverage 267 + 268 + --- 269 + 270 + ## Verification Checklist 271 + 272 + - [ ] `dune build` succeeds 273 + - [ ] `dune runtest` passes all tests in `jj_tui/lib/` 274 + - [ ] No changes to existing `%expect` blocks 275 + - [ ] `render_nodes_to_string` matches golden outputs exactly 276 + - [ ] `render_nodes_to_ui` exists and returns usable `Notty.image` 277 + 278 + --- 279 + 280 + ## File Changes 281 + 282 + | File | Action | 283 + |------|--------| 284 + | `jj_tui/lib/render_jj_graph.ml` | Extend with full implementation (~500 LOC) | 285 + | `jj_tui/lib/render_jj_graph_tests.ml` | **No changes** (golden tests) | 286 + 287 + --- 288 + 289 + ## Future Work (out of scope for this plan) 290 + 291 + - Node description text rendering 292 + - ANSI color support for graph lines 293 + - Performance optimization for large graphs 294 + 295 + --- 296 + 297 + ## Reference Materials 298 + 299 + - Rust source: `docs/renderdagsrc.md` (column.rs, renderer.rs, box_drawing.rs) 300 + - Test data: `test/jj_log.json` 301 + - Existing glyphs: `P` module in `render_jj_graph.ml`
+263
AGENTS.md
··· 1 + # AGENTS.md - Coding Agent Guidelines for jj_tui 2 + 3 + > A terminal UI for the Jujutsu version control system, built in OCaml with Notty/Nottui. 4 + 5 + ## Project Structure 6 + 7 + ``` 8 + jj_tui/ 9 + ├── bin/ # Main executable and UI components 10 + │ ├── main.ml # Entry point 11 + │ ├── jj_ui.ml # Main UI orchestration 12 + │ ├── graph_view.ml, graph_commands.ml # Commit graph UI 13 + │ ├── file_view.ml, file_commands.ml # File diff UI 14 + │ └── global_vars.ml # Shared state 15 + ├── lib/ # Core library (jj_tui) 16 + │ ├── ansiReverse.ml # ANSI escape parsing 17 + │ ├── render_jj_graph.ml # Commit graph rendering 18 + │ ├── config.ml, key_map.ml # Configuration 19 + │ └── *_tests.ml # Inline tests 20 + ├── test/lib/ # Additional test library (jj_tui_test) 21 + └── forks/ # Vendored dependencies (notty, nottui, lwd) 22 + ``` 23 + 24 + ## Build Commands 25 + 26 + **Requires Nix** - This project uses Nix for dependency management. 27 + 28 + ```bash 29 + # Enter development shell (required first) 30 + nix develop 31 + 32 + # Build 33 + dune build 34 + 35 + # Build and watch 36 + dune build --watch 37 + 38 + # Run the application 39 + dune exec jj_tui 40 + 41 + # Run all tests 42 + dune runtest 43 + 44 + # Run tests for specific library 45 + dune runtest -p jj_tui 46 + 47 + # Run tests and show output 48 + dune runtest --force 49 + 50 + # Format code 51 + dune fmt 52 + # or 53 + ocamlformat -i <file.ml> 54 + 55 + # Check formatting without applying 56 + dune fmt --preview 57 + ``` 58 + 59 + 60 + 61 + ### Running Individual Tests 62 + 63 + Tests use `ppx_expect` inline tests. To run tests in a specific file: 64 + 65 + ```bash 66 + # Run inline tests for jj_tui library 67 + dune runtest jj_tui/lib 68 + 69 + # Run inline tests for test library 70 + dune runtest jj_tui/test/lib 71 + 72 + # Promote expect test changes (update golden output) 73 + dune promote 74 + ``` 75 + ## Key things to remember: 76 + - Run `dune build` if there are type errors, often these can be caused by changes across files not being picked up which requires a rebuild 77 + - If you are getting a type error and can't fix it: Records with the same fields names can cause weird type inference issues, once it's infered it won't change. 78 + You need to either: 79 + - locally open the module with the type you want, 80 + - or do Module.( myrecord.field) 81 + - or explicitly annotate the type 82 + 83 + ## Code Style Guidelines 84 + 85 + ### OCamlformat Configuration 86 + 87 + Uses `profile = janestreet` with customizations. Key settings: 88 + - `let-binding-spacing = double-semicolon` - End let bindings with `;;` 89 + - `break-cases = nested` - Multi-line match statements 90 + - `if-then-else = keyword-first` - Align `then`/`else` 91 + - `space-around-records/lists/arrays = true` - Spacing for trailing commas 92 + 93 + ### Import/Open Patterns 94 + 95 + ```ocaml 96 + (* Module-level opens at top of file *) 97 + open Lwd_infix 98 + open Notty 99 + open Nottui 100 + open Jj_tui 101 + open! Jj_tui.Util (* open! for shadowing *) 102 + 103 + (* Functor-based module creation *) 104 + module Make (Vars : Global_vars.Vars) = struct 105 + open Vars 106 + module Process = Jj_process.Make (Vars) 107 + open Process 108 + (* ... *) 109 + end 110 + ``` 111 + 112 + ### Naming Conventions 113 + 114 + - **Functions/values**: `snake_case` - `get_hovered_rev`, `parse_escape_seq` 115 + - **Types**: `snake_case` - `ui_state_t`, `rev_id` 116 + - **Modules**: `PascalCase` - `Internal`, `Parser`, `Key_Map` 117 + - **Type parameters**: `'a`, `'acc`, `'b` 118 + - **Record fields**: `snake_case` with semicolons 119 + - **Variant constructors**: `PascalCase` - `Unique`, `Duplicate`, `Apply` 120 + 121 + ### Type Definitions 122 + 123 + ```ocaml 124 + (* Record types - use semicolons before fields *) 125 + type t = { 126 + key_map : Key_map.key_config [@updater] 127 + ; single_pane_width_threshold : int 128 + ; max_commits : int 129 + } 130 + [@@deriving yaml, record_updater ~derive:yaml] 131 + 132 + (* Variant types *) 133 + type 'a maybe_unique = 134 + | Unique of 'a 135 + | Duplicate of 'a 136 + ``` 137 + 138 + ### Error Handling 139 + 140 + ```ocaml 141 + (* Prefer Result for parsing/fallible operations *) 142 + let of_string remap = 143 + match remap with 144 + | "up" -> Ok (`Arrow `Up) 145 + | _ -> Error (`Msg ("Invalid remap: " ^ remap)) 146 + 147 + (* Use Option for optional values *) 148 + Sys.getenv_opt "XDG_CONFIG_HOME" |> Option.value ~default:"~/.config" 149 + 150 + (* Exception handling for I/O *) 151 + try 152 + let ic = open_in config_file in 153 + (* ... *) 154 + with 155 + | Sys_error _ -> default_config 156 + | ex -> [%log warn "Error: %s" (Printexc.to_string ex)]; default_config 157 + ``` 158 + 159 + ### Lwd Operators (Reactive UI) 160 + 161 + ```ocaml 162 + let ( <-$ ) f v = Lwd.map ~f (Lwd.get v) 163 + let ( $-> ) v f = Lwd.map ~f (Lwd.get v) 164 + let ( let$$ ) v f = Lwd.map ~f (Lwd.get v) 165 + let ( |>$ ) v f = Lwd.map ~f v 166 + let ( >> ) f g x = g (f x) (* Compose left-to-right *) 167 + let ( << ) f g x = f (g x) (* Compose right-to-left *) 168 + 169 + (* Usage *) 170 + let$ root = root in 171 + root |> Nottui.Ui.event_filter (...) 172 + ``` 173 + 174 + ### Logging 175 + 176 + Uses `logs-ppx` with custom timestamp wrapper: 177 + 178 + ```ocaml 179 + open Jj_tui.Logging 180 + 181 + [%log info "Loading config..."] 182 + [%log warn "Error parsing config: %s" msg] 183 + [%log debug "Old logs cleaned up"] 184 + ``` 185 + 186 + ### Testing (ppx_expect) 187 + 188 + ```ocaml 189 + let%expect_test "test_name" = 190 + let result = some_function () in 191 + print_endline result; 192 + [%expect {| 193 + expected output here 194 + |}] 195 + ;; 196 + ``` 197 + 198 + ### Documentation Comments 199 + 200 + ```ocaml 201 + (** Module-level documentation *) 202 + 203 + (** Function documentation - concise, one line preferred *) 204 + let get_unique_id maybe_unique_rev = ... 205 + 206 + (** 207 + Multi-line documentation for complex functions. 208 + Explains algorithm or non-obvious behavior. 209 + *) 210 + ``` 211 + 212 + ## Key Patterns 213 + 214 + ### Functor-Based Dependency Injection 215 + 216 + ```ocaml 217 + module Make (Vars : Global_vars.Vars) = struct 218 + (* Access Vars.* throughout the module *) 219 + end 220 + ``` 221 + 222 + ### UI State Management 223 + 224 + Global state in `Global_vars.Vars` using `Lwd.var`: 225 + 226 + ```ocaml 227 + type ui_state_t = { 228 + view : [`Main | `Cmd_I of cmd_args | ...] Lwd.var 229 + ; hovered_revision : string maybe_unique Lwd.var 230 + (* ... *) 231 + } 232 + ``` 233 + 234 + ### Command Handling 235 + 236 + Commands defined in `graph_commands.ml` / `file_commands.ml`: 237 + 238 + ```ocaml 239 + type command = 240 + | Cmd of string list 241 + | Cmd_async of string * string list 242 + | Dynamic of (unit -> command) 243 + | Selection_prompt of (...) 244 + ``` 245 + 246 + ## Dependencies 247 + 248 + Key libraries: 249 + - **nottui** / **nottui_picos**: Terminal UI framework (forked) 250 + - **lwd** / **lwd_picos**: Reactive programming (forked) 251 + - **notty**: Terminal rendering (forked) 252 + - **angstrom**: Parser combinators 253 + - **picos**: Multicore/async runtime 254 + - **ppx_expect**: Inline testing 255 + - **ppx_deriving_yaml/yojson**: Serialization 256 + 257 + ## Common Pitfalls 258 + 259 + 1. **End `let` bindings with `;;`** - Required by ocamlformat config 260 + 2. **Use Lwd operators** - Don't call `Lwd.get` directly in render functions 261 + 3. **Functor pattern** - Most modules require `Make(Vars)` instantiation 262 + 4. **Vendored forks** - Don't modify files in `forks/` unless necessary 263 + 5. **Nix required** - Build system not set up for pure opam/dune
+1335
docs/renderdagsrc.md
··· 1 + column.rs 2 + ```rs 3 + /* 4 + * Copyright (c) Meta Platforms, Inc. and affiliates. 5 + * 6 + * This source code is licensed under the MIT license found in the 7 + * LICENSE file in the root directory of this source tree. 8 + */ 9 + 10 + #[derive(Clone, Debug, PartialEq, Eq)] 11 + pub(crate) enum Column<N> { 12 + Empty, 13 + Blocked, 14 + Reserved(N), 15 + Ancestor(N), 16 + Parent(N), 17 + } 18 + 19 + impl<N> Column<N> 20 + where 21 + N: Clone, 22 + { 23 + pub(crate) fn matches(&self, n: &N) -> bool 24 + where 25 + N: Eq, 26 + { 27 + match self { 28 + Column::Empty | Column::Blocked => false, 29 + Column::Reserved(o) => n == o, 30 + Column::Ancestor(o) => n == o, 31 + Column::Parent(o) => n == o, 32 + } 33 + } 34 + 35 + fn variant(&self) -> usize { 36 + match self { 37 + Column::Empty => 0, 38 + Column::Blocked => 1, 39 + Column::Reserved(_) => 2, 40 + Column::Ancestor(_) => 3, 41 + Column::Parent(_) => 4, 42 + } 43 + } 44 + 45 + pub(crate) fn merge(&mut self, other: &Column<N>) { 46 + if other.variant() > self.variant() { 47 + *self = other.clone(); 48 + } 49 + } 50 + 51 + fn reset(&mut self) { 52 + match self { 53 + Column::Blocked => *self = Column::Empty, 54 + _ => {} 55 + } 56 + } 57 + } 58 + 59 + pub(crate) trait ColumnsExt<N> { 60 + fn find(&self, node: &N) -> Option<usize>; 61 + fn find_empty(&self, index: usize) -> Option<usize>; 62 + fn first_empty(&self) -> Option<usize>; 63 + fn new_empty(&mut self) -> usize; 64 + fn reset(&mut self); 65 + } 66 + 67 + impl<N> ColumnsExt<N> for Vec<Column<N>> 68 + where 69 + N: Clone + Eq, 70 + { 71 + fn find(&self, node: &N) -> Option<usize> { 72 + for (index, column) in self.iter().enumerate() { 73 + if column.matches(node) { 74 + return Some(index); 75 + } 76 + } 77 + None 78 + } 79 + 80 + fn find_empty(&self, index: usize) -> Option<usize> { 81 + if self.get(index) == Some(&Column::Empty) { 82 + return Some(index); 83 + } 84 + self.first_empty() 85 + } 86 + 87 + fn first_empty(&self) -> Option<usize> { 88 + for (i, column) in self.iter().enumerate() { 89 + if *column == Column::Empty { 90 + return Some(i); 91 + } 92 + } 93 + None 94 + } 95 + 96 + fn new_empty(&mut self) -> usize { 97 + self.push(Column::Empty); 98 + self.len() - 1 99 + } 100 + 101 + fn reset(&mut self) { 102 + for column in self.iter_mut() { 103 + column.reset(); 104 + } 105 + while let Some(Column::Empty) = self.last() { 106 + self.pop(); 107 + } 108 + } 109 + } 110 + ``` 111 + 112 + box_drawing.rs 113 + ```rs 114 + 115 + /* 116 + * Copyright (c) Meta Platforms, Inc. and affiliates. 117 + * 118 + * This source code is licensed under the MIT license found in the 119 + * LICENSE file in the root directory of this source tree. 120 + */ 121 + 122 + use std::marker::PhantomData; 123 + 124 + use super::output::OutputRendererOptions; 125 + use super::render::Ancestor; 126 + use super::render::GraphRow; 127 + use super::render::LinkLine; 128 + use super::render::NodeLine; 129 + use super::render::PadLine; 130 + use super::render::Renderer; 131 + use crate::pad::pad_lines; 132 + 133 + mod glyph { 134 + pub(super) const SPACE: usize = 0; 135 + pub(super) const HORIZONTAL: usize = 1; 136 + pub(super) const PARENT: usize = 2; 137 + pub(super) const ANCESTOR: usize = 3; 138 + pub(super) const MERGE_LEFT: usize = 4; 139 + pub(super) const MERGE_RIGHT: usize = 5; 140 + pub(super) const MERGE_BOTH: usize = 6; 141 + pub(super) const FORK_LEFT: usize = 7; 142 + pub(super) const FORK_RIGHT: usize = 8; 143 + pub(super) const FORK_BOTH: usize = 9; 144 + pub(super) const JOIN_LEFT: usize = 10; 145 + pub(super) const JOIN_RIGHT: usize = 11; 146 + pub(super) const JOIN_BOTH: usize = 12; 147 + pub(super) const TERMINATION: usize = 13; 148 + pub(super) const COUNT: usize = 14; 149 + } 150 + 151 + const SQUARE_GLYPHS: [&str; glyph::COUNT] = [ 152 + " ", "──", "│ ", "· ", "┘ ", "└─", "┴─", "┐ ", "┌─", "┬─", "┤ ", "├─", "┼─", "~ ", 153 + ]; 154 + 155 + const CURVED_GLYPHS: [&str; glyph::COUNT] = [ 156 + " ", "──", "│ ", "╷ ", "╯ ", "╰─", "┴─", "╮ ", "╭─", "┬─", "┤ ", "├─", "┼─", "~ ", 157 + ]; 158 + 159 + const DEC_GLYPHS: [&str; glyph::COUNT] = [ 160 + " ", 161 + "\x1B(0qq\x1B(B", 162 + "\x1B(0x \x1B(B", 163 + "\x1B(0~ \x1B(B", 164 + "\x1B(0j \x1B(B", 165 + "\x1B(0mq\x1B(B", 166 + "\x1B(0vq\x1B(B", 167 + "\x1B(0k \x1B(B", 168 + "\x1B(0lq\x1B(B", 169 + "\x1B(0wq\x1B(B", 170 + "\x1B(0u \x1B(B", 171 + "\x1B(0tq\x1B(B", 172 + "\x1B(0nq\x1B(B", 173 + "~ ", 174 + ]; 175 + 176 + impl PadLine { 177 + fn to_glyph(&self) -> usize { 178 + match *self { 179 + PadLine::Parent => glyph::PARENT, 180 + PadLine::Ancestor => glyph::ANCESTOR, 181 + PadLine::Blank => glyph::SPACE, 182 + } 183 + } 184 + } 185 + 186 + pub struct BoxDrawingRenderer<N, R> 187 + where 188 + R: Renderer<N, Output = GraphRow<N>> + Sized, 189 + { 190 + inner: R, 191 + options: OutputRendererOptions, 192 + extra_pad_line: Option<String>, 193 + glyphs: &'static [&'static str; glyph::COUNT], 194 + _phantom: PhantomData<N>, 195 + } 196 + 197 + impl<N, R> BoxDrawingRenderer<N, R> 198 + where 199 + R: Renderer<N, Output = GraphRow<N>> + Sized, 200 + { 201 + pub(crate) fn new(inner: R, options: OutputRendererOptions) -> Self { 202 + BoxDrawingRenderer { 203 + inner, 204 + options, 205 + extra_pad_line: None, 206 + glyphs: &CURVED_GLYPHS, 207 + _phantom: PhantomData, 208 + } 209 + } 210 + 211 + pub fn with_square_glyphs(mut self) -> Self { 212 + self.glyphs = &SQUARE_GLYPHS; 213 + self 214 + } 215 + 216 + pub fn with_dec_graphics_glyphs(mut self) -> Self { 217 + self.glyphs = &DEC_GLYPHS; 218 + self 219 + } 220 + } 221 + 222 + impl<N, R> Renderer<N> for BoxDrawingRenderer<N, R> 223 + where 224 + N: Clone + Eq, 225 + R: Renderer<N, Output = GraphRow<N>> + Sized, 226 + { 227 + type Output = String; 228 + 229 + fn width(&self, node: Option<&N>, parents: Option<&Vec<Ancestor<N>>>) -> u64 { 230 + self.inner 231 + .width(node, parents) 232 + .saturating_mul(2) 233 + .saturating_add(1) 234 + } 235 + 236 + fn reserve(&mut self, node: N) { 237 + self.inner.reserve(node); 238 + } 239 + 240 + fn next_row( 241 + &mut self, 242 + node: N, 243 + parents: Vec<Ancestor<N>>, 244 + glyph: String, 245 + message: String, 246 + ) -> String { 247 + let glyphs = self.glyphs; 248 + let line = self.inner.next_row(node, parents, glyph, message); 249 + let mut out = String::new(); 250 + let mut message_lines = pad_lines(line.message.lines(), self.options.min_row_height); 251 + let mut need_extra_pad_line = false; 252 + 253 + // Render the previous extra pad line 254 + if let Some(extra_pad_line) = self.extra_pad_line.take() { 255 + out.push_str(extra_pad_line.trim_end()); 256 + out.push('\n'); 257 + } 258 + 259 + // Render the nodeline 260 + let mut node_line = String::new(); 261 + for entry in line.node_line.iter() { 262 + match entry { 263 + NodeLine::Node => { 264 + node_line.push_str(&line.glyph); 265 + node_line.push(' '); 266 + } 267 + NodeLine::Parent => node_line.push_str(glyphs[glyph::PARENT]), 268 + NodeLine::Ancestor => node_line.push_str(glyphs[glyph::ANCESTOR]), 269 + NodeLine::Blank => node_line.push_str(glyphs[glyph::SPACE]), 270 + } 271 + } 272 + if let Some(msg) = message_lines.next() { 273 + node_line.push(' '); 274 + node_line.push_str(msg); 275 + } 276 + out.push_str(node_line.trim_end()); 277 + out.push('\n'); 278 + 279 + // Render the link line 280 + #[allow(clippy::if_same_then_else)] 281 + if let Some(link_row) = line.link_line { 282 + let mut link_line = String::new(); 283 + for cur in link_row.iter() { 284 + if cur.intersects(LinkLine::HORIZONTAL) { 285 + if cur.intersects(LinkLine::CHILD) { 286 + link_line.push_str(glyphs[glyph::JOIN_BOTH]); 287 + } else if cur.intersects(LinkLine::ANY_FORK) 288 + && cur.intersects(LinkLine::ANY_MERGE) 289 + { 290 + link_line.push_str(glyphs[glyph::JOIN_BOTH]); 291 + } else if cur.intersects(LinkLine::ANY_FORK) 292 + && cur.intersects(LinkLine::VERT_PARENT) 293 + && !line.merge 294 + { 295 + link_line.push_str(glyphs[glyph::JOIN_BOTH]); 296 + } else if cur.intersects(LinkLine::ANY_FORK) { 297 + link_line.push_str(glyphs[glyph::FORK_BOTH]); 298 + } else if cur.intersects(LinkLine::ANY_MERGE) { 299 + link_line.push_str(glyphs[glyph::MERGE_BOTH]); 300 + } else { 301 + link_line.push_str(glyphs[glyph::HORIZONTAL]); 302 + } 303 + } else if cur.intersects(LinkLine::VERT_PARENT) && !line.merge { 304 + let left = cur.intersects(LinkLine::LEFT_MERGE | LinkLine::LEFT_FORK); 305 + let right = cur.intersects(LinkLine::RIGHT_MERGE | LinkLine::RIGHT_FORK); 306 + match (left, right) { 307 + (true, true) => link_line.push_str(glyphs[glyph::JOIN_BOTH]), 308 + (true, false) => link_line.push_str(glyphs[glyph::JOIN_LEFT]), 309 + (false, true) => link_line.push_str(glyphs[glyph::JOIN_RIGHT]), 310 + (false, false) => link_line.push_str(glyphs[glyph::PARENT]), 311 + } 312 + } else if cur.intersects(LinkLine::VERT_PARENT | LinkLine::VERT_ANCESTOR) 313 + && !cur.intersects(LinkLine::LEFT_FORK | LinkLine::RIGHT_FORK) 314 + { 315 + let left = cur.intersects(LinkLine::LEFT_MERGE); 316 + let right = cur.intersects(LinkLine::RIGHT_MERGE); 317 + match (left, right) { 318 + (true, true) => link_line.push_str(glyphs[glyph::JOIN_BOTH]), 319 + (true, false) => link_line.push_str(glyphs[glyph::JOIN_LEFT]), 320 + (false, true) => link_line.push_str(glyphs[glyph::JOIN_RIGHT]), 321 + (false, false) => { 322 + if cur.intersects(LinkLine::VERT_ANCESTOR) { 323 + link_line.push_str(glyphs[glyph::ANCESTOR]); 324 + } else { 325 + link_line.push_str(glyphs[glyph::PARENT]); 326 + } 327 + } 328 + } 329 + } else if cur.intersects(LinkLine::LEFT_FORK) 330 + && cur.intersects(LinkLine::LEFT_MERGE | LinkLine::CHILD) 331 + { 332 + link_line.push_str(glyphs[glyph::JOIN_LEFT]); 333 + } else if cur.intersects(LinkLine::RIGHT_FORK) 334 + && cur.intersects(LinkLine::RIGHT_MERGE | LinkLine::CHILD) 335 + { 336 + link_line.push_str(glyphs[glyph::JOIN_RIGHT]); 337 + } else if cur.intersects(LinkLine::LEFT_MERGE) 338 + && cur.intersects(LinkLine::RIGHT_MERGE) 339 + { 340 + link_line.push_str(glyphs[glyph::MERGE_BOTH]); 341 + } else if cur.intersects(LinkLine::LEFT_FORK) 342 + && cur.intersects(LinkLine::RIGHT_FORK) 343 + { 344 + link_line.push_str(glyphs[glyph::FORK_BOTH]); 345 + } else if cur.intersects(LinkLine::LEFT_FORK) { 346 + link_line.push_str(glyphs[glyph::FORK_LEFT]); 347 + } else if cur.intersects(LinkLine::LEFT_MERGE) { 348 + link_line.push_str(glyphs[glyph::MERGE_LEFT]); 349 + } else if cur.intersects(LinkLine::RIGHT_FORK) { 350 + link_line.push_str(glyphs[glyph::FORK_RIGHT]); 351 + } else if cur.intersects(LinkLine::RIGHT_MERGE) { 352 + link_line.push_str(glyphs[glyph::MERGE_RIGHT]); 353 + } else { 354 + link_line.push_str(glyphs[glyph::SPACE]); 355 + } 356 + } 357 + if let Some(msg) = message_lines.next() { 358 + link_line.push(' '); 359 + link_line.push_str(msg); 360 + } 361 + out.push_str(link_line.trim_end()); 362 + out.push('\n'); 363 + } 364 + 365 + // Render the term line 366 + if let Some(term_row) = line.term_line { 367 + let term_strs = [glyphs[glyph::PARENT], glyphs[glyph::TERMINATION]]; 368 + for term_str in term_strs.iter() { 369 + let mut term_line = String::new(); 370 + for (i, term) in term_row.iter().enumerate() { 371 + if *term { 372 + term_line.push_str(term_str); 373 + } else { 374 + term_line.push_str(glyphs[line.pad_lines[i].to_glyph()]); 375 + } 376 + } 377 + if let Some(msg) = message_lines.next() { 378 + term_line.push(' '); 379 + term_line.push_str(msg); 380 + } 381 + out.push_str(term_line.trim_end()); 382 + out.push('\n'); 383 + } 384 + need_extra_pad_line = true; 385 + } 386 + 387 + let mut base_pad_line = String::new(); 388 + for entry in line.pad_lines.iter() { 389 + base_pad_line.push_str(glyphs[entry.to_glyph()]); 390 + } 391 + 392 + // Render any pad lines 393 + for msg in message_lines { 394 + let mut pad_line = base_pad_line.clone(); 395 + pad_line.push(' '); 396 + pad_line.push_str(msg); 397 + out.push_str(pad_line.trim_end()); 398 + out.push('\n'); 399 + need_extra_pad_line = false; 400 + } 401 + 402 + if need_extra_pad_line { 403 + self.extra_pad_line = Some(base_pad_line); 404 + } 405 + 406 + out 407 + } 408 + } 409 + 410 + #[cfg(test)] 411 + mod tests { 412 + use super::super::test_fixtures; 413 + use super::super::test_fixtures::TestFixture; 414 + use super::super::test_utils::render_string; 415 + use super::super::test_utils::render_string_with_order; 416 + use crate::GraphRowRenderer; 417 + 418 + fn render(fixture: &TestFixture) -> String { 419 + let mut renderer = GraphRowRenderer::new().output().build_box_drawing(); 420 + render_string(fixture, &mut renderer) 421 + } 422 + 423 + #[test] 424 + fn basic() { 425 + assert_eq!( 426 + render(&test_fixtures::BASIC), 427 + r#" 428 + o C 429 + 430 + o B 431 + 432 + o A"# 433 + ); 434 + } 435 + 436 + #[test] 437 + fn branches_and_merges() { 438 + assert_eq!( 439 + render(&test_fixtures::BRANCHES_AND_MERGES), 440 + r#" 441 + o W 442 + 443 + o V 444 + ├─╮ 445 + │ o U 446 + │ ├─╮ 447 + │ │ o T 448 + │ │ │ 449 + │ o │ S 450 + │ │ 451 + o │ R 452 + │ │ 453 + o │ Q 454 + ├─╮ │ 455 + │ o │ P 456 + │ ├───╮ 457 + │ │ │ o O 458 + │ │ │ │ 459 + │ │ │ o N 460 + │ │ │ ├─╮ 461 + │ o │ │ │ M 462 + │ │ │ │ │ 463 + │ o │ │ │ L 464 + │ │ │ │ │ 465 + o │ │ │ │ K 466 + ├───────╯ 467 + o │ │ │ J 468 + │ │ │ │ 469 + o │ │ │ I 470 + ├─╯ │ │ 471 + o │ │ H 472 + │ │ │ 473 + o │ │ G 474 + ├─────╮ 475 + │ │ o F 476 + │ ├─╯ 477 + │ o E 478 + │ │ 479 + o │ D 480 + │ │ 481 + o │ C 482 + ├───╯ 483 + o B 484 + 485 + o A"# 486 + ); 487 + } 488 + 489 + #[test] 490 + fn octopus_branch_and_merge() { 491 + assert_eq!( 492 + render(&test_fixtures::OCTOPUS_BRANCH_AND_MERGE), 493 + r#" 494 + o J 495 + ├─┬─╮ 496 + │ │ o I 497 + │ │ │ 498 + │ o │ H 499 + ╭─┼─┬─┬─╮ 500 + │ │ │ │ o G 501 + │ │ │ │ │ 502 + │ │ │ o │ E 503 + │ │ │ ├─╯ 504 + │ │ o │ D 505 + │ │ ├─╮ 506 + │ o │ │ C 507 + │ ├───╯ 508 + o │ │ F 509 + ├─╯ │ 510 + o │ B 511 + ├───╯ 512 + o A"# 513 + ); 514 + } 515 + 516 + #[test] 517 + fn reserved_column() { 518 + assert_eq!( 519 + render(&test_fixtures::RESERVED_COLUMN), 520 + r#" 521 + o Z 522 + 523 + o Y 524 + 525 + o X 526 + ╭─╯ 527 + │ o W 528 + ├─╯ 529 + o G 530 + 531 + o F 532 + ├─╮ 533 + │ o E 534 + │ │ 535 + │ o D 536 + 537 + o C 538 + 539 + o B 540 + 541 + o A"# 542 + ); 543 + } 544 + 545 + #[test] 546 + fn ancestors() { 547 + assert_eq!( 548 + render(&test_fixtures::ANCESTORS), 549 + r#" 550 + o Z 551 + 552 + o Y 553 + ╭─╯ 554 + o F 555 + 556 + ╷ o X 557 + ╭─╯ 558 + │ o W 559 + ├─╯ 560 + o E 561 + 562 + o D 563 + ├─╮ 564 + │ o C 565 + │ ╷ 566 + o ╷ B 567 + ├─╯ 568 + o A"# 569 + ); 570 + } 571 + 572 + #[test] 573 + fn split_parents() { 574 + assert_eq!( 575 + render(&test_fixtures::SPLIT_PARENTS), 576 + r#" 577 + o E 578 + ╭─┬─┬─┤ 579 + ╷ o │ ╷ D 580 + ╭─┴─╮ ╷ 581 + │ o ╷ C 582 + │ ├─╯ 583 + o │ B 584 + ├───╯ 585 + o A"# 586 + ); 587 + } 588 + 589 + #[test] 590 + fn terminations() { 591 + assert_eq!( 592 + render(&test_fixtures::TERMINATIONS), 593 + r#" 594 + o K 595 + 596 + │ o J 597 + ├─╯ 598 + o I 599 + ╭─┼─╮ 600 + │ │ │ 601 + │ ~ │ 602 + │ │ 603 + │ o H 604 + │ │ 605 + o │ E 606 + ├───╯ 607 + o D 608 + 609 + ~ 610 + 611 + o C 612 + 613 + o B 614 + 615 + ~"# 616 + ); 617 + } 618 + 619 + #[test] 620 + fn long_messages() { 621 + assert_eq!( 622 + render(&test_fixtures::LONG_MESSAGES), 623 + r#" 624 + o F 625 + ├─┬─╮ very long message 1 626 + │ │ │ very long message 2 627 + │ │ ~ very long message 3 628 + │ │ 629 + │ │ very long message 4 630 + │ │ very long message 5 631 + │ │ very long message 6 632 + │ │ 633 + │ o E 634 + │ │ 635 + │ o D 636 + │ │ 637 + o │ C 638 + ├─╯ long message 1 639 + │ long message 2 640 + │ long message 3 641 + 642 + o B 643 + 644 + o A 645 + │ long message 1 646 + ~ long message 2 647 + long message 3"# 648 + ); 649 + } 650 + 651 + #[test] 652 + fn different_orders() { 653 + let order = |order: &str| { 654 + let order = order.matches(|_: char| true).collect::<Vec<_>>(); 655 + let mut renderer = GraphRowRenderer::new().output().build_box_drawing(); 656 + render_string_with_order(&test_fixtures::ORDERS1, &mut renderer, Some(&order)) 657 + }; 658 + 659 + assert_eq!( 660 + order("KJIHGFEDCBZA"), 661 + r#" 662 + o K 663 + ├─╮ 664 + │ o J 665 + │ ├─╮ 666 + │ │ o I 667 + │ │ ├─╮ 668 + │ │ │ o H 669 + │ │ │ ├─╮ 670 + │ │ │ │ o G 671 + │ │ │ │ ├─╮ 672 + o │ │ │ │ │ F 673 + │ │ │ │ │ │ 674 + │ o │ │ │ │ E 675 + ├─╯ │ │ │ │ 676 + │ o │ │ │ D 677 + ├───╯ │ │ │ 678 + │ o │ │ C 679 + ├─────╯ │ │ 680 + │ o │ B 681 + ├───────╯ │ 682 + │ o Z 683 + 684 + o A"# 685 + ); 686 + 687 + assert_eq!( 688 + order("KJIHGZBCDEFA"), 689 + r#" 690 + o K 691 + ├─╮ 692 + │ o J 693 + │ ├─╮ 694 + │ │ o I 695 + │ │ ├─╮ 696 + │ │ │ o H 697 + │ │ │ ├─╮ 698 + │ │ │ │ o G 699 + │ │ │ │ ├─╮ 700 + │ │ │ │ │ o Z 701 + │ │ │ │ │ 702 + │ │ │ │ o B 703 + │ │ │ │ │ 704 + │ │ │ o │ C 705 + │ │ │ ├─╯ 706 + │ │ o │ D 707 + │ │ ├─╯ 708 + │ o │ E 709 + │ ├─╯ 710 + o │ F 711 + ├─╯ 712 + o A"# 713 + ); 714 + 715 + // Keeping the p1 branch the longest path (KFEDCBA) is a reasonable 716 + // optimization for a cleaner graph (less columns, more text space). 717 + assert_eq!( 718 + render(&test_fixtures::ORDERS2), 719 + r#" 720 + o K 721 + ├─╮ 722 + │ o J 723 + │ │ 724 + o │ F 725 + ├───╮ 726 + │ │ o I 727 + │ ├─╯ 728 + o │ E 729 + ├───╮ 730 + │ │ o H 731 + │ ├─╯ 732 + o │ D 733 + ├───╮ 734 + │ │ o G 735 + │ ├─╯ 736 + o │ C 737 + ├───╮ 738 + │ │ o Z 739 + │ │ 740 + o │ B 741 + ├─╯ 742 + o A"# 743 + ); 744 + 745 + // Try to use the ORDERS2 order. However, the parent ordering in the 746 + // graph is different, which makes the rendering different. 747 + // 748 + // Note: it's KJFIEHDGCZBA in the ORDERS2 graph. To map it to ORDERS1, 749 + // follow: 750 + // 751 + // ORDERS1: KFJEIDHCGBZA 752 + // ORDERS2: KJFIEHDGCBZA 753 + // 754 + // And we get KFJEIDHCGZBA. 755 + assert_eq!( 756 + order("KFJEIDHCGZBA"), 757 + r#" 758 + o K 759 + ├─╮ 760 + o │ F 761 + │ │ 762 + │ o J 763 + │ ├─╮ 764 + │ o │ E 765 + ├─╯ │ 766 + │ o I 767 + │ ╭─┤ 768 + │ │ o D 769 + ├───╯ 770 + │ o H 771 + │ ├─╮ 772 + │ o │ C 773 + ├─╯ │ 774 + │ o G 775 + │ ╭─┤ 776 + │ o │ Z 777 + │ │ 778 + │ o B 779 + ├───╯ 780 + o A"# 781 + ); 782 + } 783 + } 784 + ``` 785 + 786 + renderer.rs 787 + ```rs 788 + 789 + /* 790 + * Copyright (c) Meta Platforms, Inc. and affiliates. 791 + * 792 + * This source code is licensed under the MIT license found in the 793 + * LICENSE file in the root directory of this source tree. 794 + */ 795 + 796 + use std::collections::BTreeMap; 797 + use std::ops::Range; 798 + 799 + use bitflags::bitflags; 800 + #[cfg(feature = "serialize")] 801 + use serde::Serialize; 802 + 803 + use super::column::Column; 804 + use super::column::ColumnsExt; 805 + use super::output::OutputRendererBuilder; 806 + 807 + pub trait Renderer<N> { 808 + type Output; 809 + 810 + // Returns the width of the graph line, possibly including another node. 811 + fn width(&self, new_node: Option<&N>, new_parents: Option<&Vec<Ancestor<N>>>) -> u64; 812 + 813 + // Reserve a column for the given node. 814 + fn reserve(&mut self, node: N); 815 + 816 + // Render the next row. 817 + fn next_row( 818 + &mut self, 819 + node: N, 820 + parents: Vec<Ancestor<N>>, 821 + glyph: String, 822 + message: String, 823 + ) -> Self::Output; 824 + } 825 + 826 + /// Renderer for a DAG. 827 + /// 828 + /// Converts a sequence of DAG node descriptions into rendered graph rows. 829 + pub struct GraphRowRenderer<N> { 830 + columns: Vec<Column<N>>, 831 + } 832 + 833 + /// Ancestor type indication for an ancestor or parent node. 834 + pub enum Ancestor<N> { 835 + /// The node is an eventual ancestor. 836 + Ancestor(N), 837 + 838 + /// The node is an immediate parent. 839 + Parent(N), 840 + 841 + /// The node is an anonymous ancestor. 842 + Anonymous, 843 + } 844 + 845 + impl<N> Ancestor<N> { 846 + fn to_column(&self) -> Column<N> 847 + where 848 + N: Clone, 849 + { 850 + match self { 851 + Ancestor::Ancestor(n) => Column::Ancestor(n.clone()), 852 + Ancestor::Parent(n) => Column::Parent(n.clone()), 853 + Ancestor::Anonymous => Column::Blocked, 854 + } 855 + } 856 + 857 + fn id(&self) -> Option<&N> { 858 + match self { 859 + Ancestor::Ancestor(n) => Some(n), 860 + Ancestor::Parent(n) => Some(n), 861 + Ancestor::Anonymous => None, 862 + } 863 + } 864 + 865 + fn is_direct(&self) -> bool { 866 + match self { 867 + Ancestor::Ancestor(_) => false, 868 + Ancestor::Parent(_) => true, 869 + Ancestor::Anonymous => true, 870 + } 871 + } 872 + 873 + fn to_link_line(&self, direct: LinkLine, indirect: LinkLine) -> LinkLine { 874 + if self.is_direct() { direct } else { indirect } 875 + } 876 + } 877 + 878 + struct AncestorColumnBounds { 879 + target: usize, 880 + min_ancestor: usize, 881 + min_parent: usize, 882 + max_parent: usize, 883 + max_ancestor: usize, 884 + } 885 + 886 + impl AncestorColumnBounds { 887 + fn new<N>(columns: &BTreeMap<usize, &Ancestor<N>>, target: usize) -> Option<Self> { 888 + if columns.is_empty() { 889 + return None; 890 + } 891 + let min_ancestor = columns 892 + .iter() 893 + .next() 894 + .map_or(target, |(index, _)| *index) 895 + .min(target); 896 + let max_ancestor = columns 897 + .iter() 898 + .next_back() 899 + .map_or(target, |(index, _)| *index) 900 + .max(target); 901 + let min_parent = columns 902 + .iter() 903 + .find(|(_, ancestor)| ancestor.is_direct()) 904 + .map_or(target, |(index, _)| *index) 905 + .min(target); 906 + let max_parent = columns 907 + .iter() 908 + .rev() 909 + .find(|(_, ancestor)| ancestor.is_direct()) 910 + .map_or(target, |(index, _)| *index) 911 + .max(target); 912 + Some(Self { 913 + target, 914 + min_ancestor, 915 + min_parent, 916 + max_parent, 917 + max_ancestor, 918 + }) 919 + } 920 + 921 + fn range(&self) -> Range<usize> { 922 + if self.min_ancestor < self.max_ancestor { 923 + self.min_ancestor + 1..self.max_ancestor 924 + } else { 925 + Default::default() 926 + } 927 + } 928 + 929 + fn horizontal_line(&self, index: usize) -> LinkLine { 930 + if index == self.target { 931 + LinkLine::empty() 932 + } else if index > self.min_parent && index < self.max_parent { 933 + LinkLine::HORIZ_PARENT 934 + } else if index > self.min_ancestor && index < self.max_ancestor { 935 + LinkLine::HORIZ_ANCESTOR 936 + } else { 937 + LinkLine::empty() 938 + } 939 + } 940 + } 941 + 942 + impl<N> Column<N> { 943 + fn to_node_line(&self) -> NodeLine { 944 + match self { 945 + Column::Ancestor(_) => NodeLine::Ancestor, 946 + Column::Parent(_) => NodeLine::Parent, 947 + _ => NodeLine::Blank, 948 + } 949 + } 950 + 951 + fn to_link_line(&self) -> LinkLine { 952 + match self { 953 + Column::Ancestor(_) => LinkLine::VERT_ANCESTOR, 954 + Column::Parent(_) => LinkLine::VERT_PARENT, 955 + _ => LinkLine::empty(), 956 + } 957 + } 958 + 959 + fn to_pad_line(&self) -> PadLine { 960 + match self { 961 + Column::Ancestor(_) => PadLine::Ancestor, 962 + Column::Parent(_) => PadLine::Parent, 963 + _ => PadLine::Blank, 964 + } 965 + } 966 + } 967 + 968 + /// A column in the node row. 969 + #[derive(Debug, Copy, Clone, PartialEq, Eq)] 970 + #[cfg_attr(feature = "serialize", derive(Serialize))] 971 + pub enum NodeLine { 972 + /// Blank. 973 + Blank, 974 + 975 + /// Vertical line indicating an ancestor. 976 + Ancestor, 977 + 978 + /// Vertical line indicating a parent. 979 + Parent, 980 + 981 + /// The node for this row. 982 + Node, 983 + } 984 + 985 + /// A column in a padding row. 986 + #[derive(Debug, Copy, Clone, PartialEq, Eq)] 987 + #[cfg_attr(feature = "serialize", derive(Serialize))] 988 + pub enum PadLine { 989 + /// Blank. 990 + Blank, 991 + 992 + /// Vertical line indicating an ancestor. 993 + Ancestor, 994 + 995 + /// Vertical line indicating a parent. 996 + Parent, 997 + } 998 + 999 + bitflags! { 1000 + /// A column in a linking row. 1001 + #[cfg_attr(feature = "serialize", derive(Serialize))] 1002 + #[derive(Default, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)] 1003 + pub struct LinkLine: u16 { 1004 + /// This cell contains a horizontal line that connects to a parent. 1005 + const HORIZ_PARENT = 0b0_0000_0000_0001; 1006 + 1007 + /// This cell contains a horizontal line that connects to an ancestor. 1008 + const HORIZ_ANCESTOR = 0b0_0000_0000_0010; 1009 + 1010 + /// The descendent of this cell is connected to the parent. 1011 + const VERT_PARENT = 0b0_0000_0000_0100; 1012 + 1013 + /// The descendent of this cell is connected to an ancestor. 1014 + const VERT_ANCESTOR = 0b0_0000_0000_1000; 1015 + 1016 + /// The parent of this cell is linked in this link row and the child 1017 + /// is to the left. 1018 + const LEFT_FORK_PARENT = 0b0_0000_0001_0000; 1019 + 1020 + /// The ancestor of this cell is linked in this link row and the child 1021 + /// is to the left. 1022 + const LEFT_FORK_ANCESTOR = 0b0_0000_0010_0000; 1023 + 1024 + /// The parent of this cell is linked in this link row and the child 1025 + /// is to the right. 1026 + const RIGHT_FORK_PARENT = 0b0_0000_0100_0000; 1027 + 1028 + /// The ancestor of this cell is linked in this link row and the child 1029 + /// is to the right. 1030 + const RIGHT_FORK_ANCESTOR = 0b0_0000_1000_0000; 1031 + 1032 + /// The child of this cell is linked to parents on the left. 1033 + const LEFT_MERGE_PARENT = 0b0_0001_0000_0000; 1034 + 1035 + /// The child of this cell is linked to ancestors on the left. 1036 + const LEFT_MERGE_ANCESTOR = 0b0_0010_0000_0000; 1037 + 1038 + /// The child of this cell is linked to parents on the right. 1039 + const RIGHT_MERGE_PARENT = 0b0_0100_0000_0000; 1040 + 1041 + /// The child of this cell is linked to ancestors on the right. 1042 + const RIGHT_MERGE_ANCESTOR = 0b0_1000_0000_0000; 1043 + 1044 + /// The target node of this link line is the child of this column. 1045 + /// This disambiguates between the node that is connected in this link 1046 + /// line, and other nodes that are also connected vertically. 1047 + const CHILD = 0b1_0000_0000_0000; 1048 + 1049 + const HORIZONTAL = Self::HORIZ_PARENT.bits() | Self::HORIZ_ANCESTOR.bits(); 1050 + const VERTICAL = Self::VERT_PARENT.bits() | Self::VERT_ANCESTOR.bits(); 1051 + const LEFT_FORK = Self::LEFT_FORK_PARENT.bits() | Self::LEFT_FORK_ANCESTOR.bits(); 1052 + const RIGHT_FORK = Self::RIGHT_FORK_PARENT.bits() | Self::RIGHT_FORK_ANCESTOR.bits(); 1053 + const LEFT_MERGE = Self::LEFT_MERGE_PARENT.bits() | Self::LEFT_MERGE_ANCESTOR.bits(); 1054 + const RIGHT_MERGE = Self::RIGHT_MERGE_PARENT.bits() | Self::RIGHT_MERGE_ANCESTOR.bits(); 1055 + const ANY_MERGE = Self::LEFT_MERGE.bits() | Self::RIGHT_MERGE.bits(); 1056 + const ANY_FORK = Self::LEFT_FORK.bits() | Self::RIGHT_FORK.bits(); 1057 + const ANY_FORK_OR_MERGE = Self::ANY_MERGE.bits() | Self::ANY_FORK.bits(); 1058 + } 1059 + } 1060 + 1061 + /// An output graph row. 1062 + #[derive(Debug)] 1063 + #[cfg_attr(feature = "serialize", derive(Serialize))] 1064 + pub struct GraphRow<N> { 1065 + /// The name of the node for this row. 1066 + pub node: N, 1067 + 1068 + /// The glyph for this node. 1069 + pub glyph: String, 1070 + 1071 + /// The message for this row. 1072 + pub message: String, 1073 + 1074 + /// True if this row is for a merge commit. 1075 + pub merge: bool, 1076 + 1077 + /// The node columns for this row. 1078 + pub node_line: Vec<NodeLine>, 1079 + 1080 + /// The link columns for this row, if a link row is necessary. 1081 + pub link_line: Option<Vec<LinkLine>>, 1082 + 1083 + /// The location of any terminators, if necessary. Other columns should be 1084 + /// filled in with pad lines. 1085 + pub term_line: Option<Vec<bool>>, 1086 + 1087 + /// The pad columns for this row. 1088 + pub pad_lines: Vec<PadLine>, 1089 + } 1090 + 1091 + impl<N> GraphRowRenderer<N> 1092 + where 1093 + N: Clone + Eq + std::fmt::Debug, 1094 + { 1095 + /// Create a new renderer. 1096 + pub fn new() -> Self { 1097 + GraphRowRenderer { 1098 + columns: Vec::new(), 1099 + } 1100 + } 1101 + 1102 + /// Build an output renderer from this renderer. 1103 + pub fn output(self) -> OutputRendererBuilder<N, Self> { 1104 + OutputRendererBuilder::new(self) 1105 + } 1106 + } 1107 + 1108 + impl<N> Renderer<N> for GraphRowRenderer<N> 1109 + where 1110 + N: Clone + Eq + std::fmt::Debug, 1111 + { 1112 + type Output = GraphRow<N>; 1113 + 1114 + fn width(&self, node: Option<&N>, parents: Option<&Vec<Ancestor<N>>>) -> u64 { 1115 + let mut width = self.columns.len(); 1116 + let mut empty_columns = self 1117 + .columns 1118 + .iter() 1119 + .filter(|&column| column == &Column::Empty) 1120 + .count(); 1121 + if let Some(node) = node { 1122 + // If the node is not already allocated, and there is no 1123 + // space for the node, then adding the new node would create 1124 + // a new column. 1125 + if self.columns.find(node).is_none() { 1126 + if empty_columns == 0 { 1127 + width += 1; 1128 + } else { 1129 + empty_columns = empty_columns.saturating_sub(1); 1130 + } 1131 + } 1132 + } 1133 + if let Some(parents) = parents { 1134 + // Non-allocated parents will also need a new column (except 1135 + // for one, which can take the place of the node, and any that could be allocated to 1136 + // empty columns). 1137 + let unallocated_parents = parents 1138 + .iter() 1139 + .filter(|parent| { 1140 + parent 1141 + .id() 1142 + .is_none_or(|parent| self.columns.find(parent).is_none()) 1143 + }) 1144 + .count() 1145 + .saturating_sub(empty_columns); 1146 + width += unallocated_parents.saturating_sub(1); 1147 + } 1148 + width as u64 1149 + } 1150 + 1151 + fn reserve(&mut self, node: N) { 1152 + if self.columns.find(&node).is_none() { 1153 + if let Some(index) = self.columns.first_empty() { 1154 + self.columns[index] = Column::Reserved(node); 1155 + } else { 1156 + self.columns.push(Column::Reserved(node)); 1157 + } 1158 + } 1159 + } 1160 + 1161 + fn next_row( 1162 + &mut self, 1163 + node: N, 1164 + parents: Vec<Ancestor<N>>, 1165 + glyph: String, 1166 + message: String, 1167 + ) -> GraphRow<N> { 1168 + // Find a column for this node. 1169 + let column = self.columns.find(&node).unwrap_or_else(|| { 1170 + self.columns 1171 + .first_empty() 1172 + .unwrap_or_else(|| self.columns.new_empty()) 1173 + }); 1174 + self.columns[column] = Column::Empty; 1175 + 1176 + // This row is for a merge if there are multiple parents. 1177 + let merge = parents.len() > 1; 1178 + 1179 + // Build the initial node line. 1180 + let mut node_line: Vec<_> = self.columns.iter().map(|c| c.to_node_line()).collect(); 1181 + node_line[column] = NodeLine::Node; 1182 + 1183 + // Build the initial link line. 1184 + let mut link_line: Vec<_> = self.columns.iter().map(|c| c.to_link_line()).collect(); 1185 + let mut need_link_line = false; 1186 + 1187 + // Build the initial term line. 1188 + let mut term_line: Vec<_> = self.columns.iter().map(|_| false).collect(); 1189 + let mut need_term_line = false; 1190 + 1191 + // Build the initial pad line. 1192 + let mut pad_lines: Vec<_> = self.columns.iter().map(|c| c.to_pad_line()).collect(); 1193 + 1194 + // Assign each parent to a column. 1195 + let mut parent_columns = BTreeMap::new(); 1196 + for p in parents.iter() { 1197 + // Check if the parent already has a column. 1198 + if let Some(parent_id) = p.id() { 1199 + if let Some(index) = self.columns.find(parent_id) { 1200 + self.columns[index].merge(&p.to_column()); 1201 + parent_columns.insert(index, p); 1202 + continue; 1203 + } 1204 + } 1205 + // Assign the parent to an empty column, preferring the column 1206 + // the current node is going in, to maintain linearity. 1207 + if let Some(index) = self.columns.find_empty(column) { 1208 + self.columns[index].merge(&p.to_column()); 1209 + parent_columns.insert(index, p); 1210 + continue; 1211 + } 1212 + // There are no empty columns left. Make a new column. 1213 + parent_columns.insert(self.columns.len(), p); 1214 + node_line.push(NodeLine::Blank); 1215 + pad_lines.push(PadLine::Blank); 1216 + link_line.push(LinkLine::default()); 1217 + term_line.push(false); 1218 + self.columns.push(p.to_column()); 1219 + } 1220 + 1221 + // Mark parent columns with anonymous parents as terminating. 1222 + for (i, p) in parent_columns.iter() { 1223 + if p.id().is_none() { 1224 + term_line[*i] = true; 1225 + need_term_line = true; 1226 + } 1227 + } 1228 + 1229 + // Check if we can move the parent to the current column. 1230 + if parents.len() == 1 { 1231 + if let Some((&parent_column, _)) = parent_columns.iter().next() { 1232 + if parent_column > column { 1233 + // This node has a single parent which was already 1234 + // assigned to a column to the right of this one. 1235 + // Move the parent to this column. 1236 + self.columns.swap(column, parent_column); 1237 + let parent = parent_columns 1238 + .remove(&parent_column) 1239 + .expect("parent should exist"); 1240 + parent_columns.insert(column, parent); 1241 + 1242 + // Generate a line from this column to the old 1243 + // parent column. We need to continue with the style 1244 + // that was being used for the parent column. 1245 + let was_direct = link_line[parent_column].contains(LinkLine::VERT_PARENT); 1246 + link_line[column] |= if was_direct { 1247 + LinkLine::RIGHT_FORK_PARENT 1248 + } else { 1249 + LinkLine::RIGHT_FORK_ANCESTOR 1250 + }; 1251 + #[allow(clippy::needless_range_loop)] 1252 + for i in column + 1..parent_column { 1253 + link_line[i] |= if was_direct { 1254 + LinkLine::HORIZ_PARENT 1255 + } else { 1256 + LinkLine::HORIZ_ANCESTOR 1257 + }; 1258 + } 1259 + link_line[parent_column] = if was_direct { 1260 + LinkLine::LEFT_MERGE_PARENT 1261 + } else { 1262 + LinkLine::LEFT_MERGE_ANCESTOR 1263 + }; 1264 + need_link_line = true; 1265 + // The pad line for the old parent column is now blank. 1266 + pad_lines[parent_column] = PadLine::Blank; 1267 + } 1268 + } 1269 + } 1270 + 1271 + // Connect the node column to all the parent columns. 1272 + if let Some(bounds) = AncestorColumnBounds::new(&parent_columns, column) { 1273 + // If the parents extend beyond the columns adjacent to the node, draw a horizontal 1274 + // ancestor line between the two outermost ancestors. 1275 + for i in bounds.range() { 1276 + link_line[i] |= bounds.horizontal_line(i); 1277 + need_link_line = true; 1278 + } 1279 + 1280 + // If there is a parent or ancestor to the right of the node 1281 + // column, the node merges from the right. 1282 + if bounds.max_parent > column { 1283 + link_line[column] |= LinkLine::RIGHT_MERGE_PARENT; 1284 + need_link_line = true; 1285 + } else if bounds.max_ancestor > column { 1286 + link_line[column] |= LinkLine::RIGHT_MERGE_ANCESTOR; 1287 + need_link_line = true; 1288 + } 1289 + 1290 + // If there is a parent or ancestor to the left of the node column, the node merges from the left. 1291 + if bounds.min_parent < column { 1292 + link_line[column] |= LinkLine::LEFT_MERGE_PARENT; 1293 + need_link_line = true; 1294 + } else if bounds.min_ancestor < column { 1295 + link_line[column] |= LinkLine::LEFT_MERGE_ANCESTOR; 1296 + need_link_line = true; 1297 + } 1298 + 1299 + // Each parent or ancestor forks towards the node column. 1300 + #[allow(clippy::comparison_chain)] 1301 + for (&i, p) in parent_columns.iter() { 1302 + pad_lines[i] = self.columns[i].to_pad_line(); 1303 + if i < column { 1304 + link_line[i] |= 1305 + p.to_link_line(LinkLine::RIGHT_FORK_PARENT, LinkLine::RIGHT_FORK_ANCESTOR); 1306 + } else if i == column { 1307 + link_line[i] |= LinkLine::CHILD 1308 + | p.to_link_line(LinkLine::VERT_PARENT, LinkLine::VERT_ANCESTOR); 1309 + } else { 1310 + link_line[i] |= 1311 + p.to_link_line(LinkLine::LEFT_FORK_PARENT, LinkLine::LEFT_FORK_ANCESTOR); 1312 + } 1313 + } 1314 + } 1315 + 1316 + // Now that we have assigned all the columns, reset their state. 1317 + self.columns.reset(); 1318 + 1319 + // Filter out the link line or term line if they are not needed. 1320 + let link_line = Some(link_line).filter(|_| need_link_line); 1321 + let term_line = Some(term_line).filter(|_| need_term_line); 1322 + 1323 + GraphRow { 1324 + node, 1325 + glyph, 1326 + message, 1327 + merge, 1328 + node_line, 1329 + link_line, 1330 + term_line, 1331 + pad_lines, 1332 + } 1333 + } 1334 + } 1335 + ```
+85 -16
jj_tui/bin/graph_view.ml
··· 16 16 (* Import graph commands *) 17 17 module GraphCommands = Graph_commands.Make (Vars) 18 18 19 + (** Render commit content for a node - shows change_id, author, timestamp, description, bookmarks *) 20 + let render_commit_content (node : Render_jj_graph.node) : Notty.image = 21 + let open Notty in 22 + let open Notty.A in 23 + let styled_text attr text = I.string attr text in 24 + let change_id_short = 25 + String.sub node.change_id 0 (min 8 (String.length node.change_id)) 26 + in 27 + let author_name = 28 + match String.split_on_char '@' node.author_email with 29 + | name :: _ -> 30 + name 31 + | [] -> 32 + node.author_email 33 + in 34 + let description_line = 35 + match String.split_on_char '\n' node.description with 36 + | first :: _ when String.trim first <> "" -> 37 + String.trim first 38 + | _ -> 39 + "(no description set)" 40 + in 41 + let parts = ref [] in 42 + let change_id_attr = 43 + if node.is_preview 44 + then fg lightblack ++ st dim 45 + else if node.working_copy 46 + then fg lightcyan ++ st bold 47 + else if node.immutable 48 + then fg lightmagenta 49 + else if node.empty 50 + then fg yellow 51 + else fg cyan 52 + in 53 + parts := styled_text change_id_attr change_id_short :: !parts; 54 + parts := styled_text (fg white ++ st dim) (" " ^ author_name) :: !parts; 55 + parts := styled_text (fg white ++ st dim) (" " ^ node.author_timestamp) :: !parts; 56 + if List.length node.bookmarks > 0 57 + then ( 58 + let bookmarks_str = " (" ^ String.concat ", " node.bookmarks ^ ")" in 59 + parts := styled_text (fg green ++ st bold) bookmarks_str :: !parts); 60 + let desc_attr = 61 + if node.is_preview || node.empty 62 + then fg white ++ st dim 63 + else if node.wip 64 + then fg lightyellow 65 + else fg white 66 + in 67 + parts := styled_text desc_attr (" " ^ description_line) :: !parts; 68 + !parts |> List.rev |> I.hcat 69 + ;; 70 + 71 + (** Render a graph row by combining graph prefix with content *) 72 + let render_graph_row 73 + (row : Render_jj_graph.graph_row_output) 74 + ~(render_content : Render_jj_graph.node -> Notty.image) : Notty.image 75 + = 76 + let open Notty in 77 + let graph_img = I.string A.empty row.graph_chars in 78 + match row.row_type with 79 + | NodeRow -> 80 + let content_img = render_content row.node in 81 + I.hcat [ graph_img; content_img ] 82 + | LinkRow | PadRow | TermRow -> 83 + graph_img 84 + ;; 85 + 19 86 let bookmark_select_prompt get_bookmark_list name func = 20 87 Selection_prompt 21 88 ( name ··· 59 126 |> Option.value ~default:(Ui.empty |> Lwd.pure) 60 127 in 61 128 let items = 62 - let$ graph, rev_ids = 129 + let$ rendered_rows, rev_ids = 63 130 (*TODO I think this ads a slight delay to everything becasue it makes things need to be renedered twice. maybe I could try getting rid of it*) 64 131 Vars.ui_state.trigger_update 65 132 |> Lwd.get 66 133 |> Lwd.map2 (Lwd.get Vars.ui_state.revset) ~f:(fun revset _ -> 67 134 try 68 135 let max_commits = (Vars.config |> Lwd.peek).max_commits in 69 - let res = graph_and_revs ?revset max_commits () in 136 + let nodes, rev_ids = get_graph_nodes ?revset max_commits in 137 + let state = 138 + Render_jj_graph.{ depth = 0; columns = [||]; pending_joins = [] } 139 + in 140 + let rendered_rows = Render_jj_graph.render_nodes_structured state nodes in 70 141 error_var $= None; 71 - res 142 + rendered_rows, rev_ids 72 143 with 73 144 | Jj_process.JJError (cmd, error) -> 74 145 (*If we have an error generating the graph,likely because the revset is wrong,just show the errror*) 75 146 error_var $= Some (error |> Jj_tui.AnsiReverse.colored_string |> Ui.atom); 76 - [||], [||]) 147 + [], [||]) 77 148 in 78 149 (*We will make two arrays, one with both selectable and filler and one with only selectable*) 79 150 let selectable_idx = ref 0 in 80 - let selectable_items = Array.make (Array.length graph) (Obj.magic ()) in 151 + let selectable_items = Array.make (Array.length rev_ids) (Obj.magic ()) in 81 152 let items = 82 - graph 83 - |> Array.map (fun x -> 84 - match x with 85 - | `Selectable x -> 153 + rendered_rows 154 + |> List.map (fun (row : Render_jj_graph.graph_row_output) -> 155 + match row.row_type with 156 + | NodeRow -> 86 157 let ui = 87 158 W.Lists.selectable_item 88 - (x 89 - (* TODO This won't work if we are on a branch, because that puts the @ further out*) 90 - |> Jj_tui.AnsiReverse.colored_string 91 - |> Ui.atom) 159 + (render_graph_row row ~render_content:render_commit_content |> Ui.atom) 92 160 in 93 161 let id = rev_ids.(!selectable_idx) in 94 162 let data = ··· 103 171 Array.set selectable_items !selectable_idx data; 104 172 selectable_idx := !selectable_idx + 1; 105 173 W.Lists.(Selectable data) 106 - | `Filler x -> 107 - W.Lists.( 108 - Filler (x |> Jj_tui.AnsiReverse.colored_string |> Ui.atom |> Lwd.pure))) 174 + | LinkRow | PadRow | TermRow -> 175 + let graph_img = I.string A.empty row.graph_chars in 176 + W.Lists.(Filler (graph_img |> Ui.atom |> Lwd.pure))) 177 + |> Array.of_list 109 178 in 110 179 items 111 180 in
+6 -6
jj_tui/lib/ansiReverseTests.ml
··· 288 288 Internal.print_image img; 289 289 [%expect 290 290 {| 291 - image: 292 - 293 - (x ^ "\n" 294 - (x 295 - 296 - |}] 291 + image: 292 +  293 +  (x ^ "\n" 294 +  (x  295 +   296 + |}] 297 297 ;; 298 298 end
-1
jj_tui/lib/config.ml
··· 1 - open Util 2 1 open Logging 3 2 4 3 type t = {
+144
jj_tui/lib/jj_json.ml
··· 1 + (** 2 + `jj_json.ml` 3 + 4 + Module for parsing jj log JSON output and converting to render_jj_graph nodes. 5 + Provides types and functions to: 6 + - Define the jj template for JSON output 7 + - Parse JSONL (JSON Lines) output from jj log 8 + - Convert parsed commits to render_jj_graph.node list 9 + *) 10 + 11 + (** Author information from jj log output *) 12 + type jj_author = { 13 + email : string 14 + ; timestamp : string 15 + } 16 + [@@deriving yojson] 17 + 18 + (** Commit information from jj log JSON output *) 19 + type jj_commit = { 20 + commit_id : string 21 + ; parents : string list 22 + ; change_id : string 23 + ; description : string 24 + ; working_copy : bool 25 + ; immutable : bool 26 + ; wip : bool 27 + ; hidden : bool 28 + ; divergent : bool 29 + ; empty : bool 30 + ; bookmarks : string list 31 + ; author : jj_author 32 + } 33 + [@@deriving yojson] 34 + 35 + (** The jj template that produces JSONL output *) 36 + let json_log_template = 37 + {|'{' 38 + ++ '"commit_id":' ++ json(commit_id) 39 + ++ ',"parents":[' ++ parents.map(|c| json(c.commit_id())).join(",") ++ ']' 40 + ++ ',"change_id":' ++ json(change_id) 41 + ++ ',"description":' ++ json(description) 42 + ++ ',"working_copy":' ++ json(current_working_copy) 43 + ++ ',"immutable":' ++ json(immutable) 44 + ++ ',"wip":' ++ json(description.first_line().starts_with("wip:")) 45 + ++ ',"hidden":' ++ json(hidden) 46 + ++ ',"divergent":' ++ json(divergent) 47 + ++ ',"empty":' ++ json(empty) 48 + ++ ',"bookmarks":[' ++ bookmarks.map(|b| json(b.name())).join(",") ++ ']' 49 + ++ ',"author":{"email":' ++ json(author.email()) ++ ',"timestamp":' ++ json(author.timestamp()) ++ '}' 50 + ++ '} 51 + '|} 52 + ;; 53 + 54 + (** Parse JSONL (one JSON object per line) from jj log output *) 55 + let parse_jj_log_output (input : string) : (jj_commit list, string) result = 56 + try 57 + let lines = 58 + input |> String.split_on_char '\n' |> List.filter (fun s -> String.length s > 0) 59 + in 60 + let commits = 61 + lines 62 + |> List.map (fun line -> 63 + let json = Yojson.Safe.from_string line in 64 + match jj_commit_of_yojson json with 65 + | Ok commit -> 66 + commit 67 + | Error msg -> 68 + failwith (Printf.sprintf "Failed to parse commit JSON: %s" msg)) 69 + in 70 + Ok commits 71 + with 72 + | Failure msg -> 73 + Error (Printf.sprintf "Parse error: %s" msg) 74 + | Yojson.Json_error msg -> 75 + Error (Printf.sprintf "JSON error: %s" msg) 76 + | ex -> 77 + Error (Printf.sprintf "Unexpected error: %s" (Printexc.to_string ex)) 78 + ;; 79 + 80 + (** Convert list of jj_commit to render_jj_graph.node list. 81 + Uses two-pass approach: create nodes, then link parents. 82 + 83 + The two-pass algorithm is required because parent references must point to 84 + the exact same objects in memory (physical equality ==). This is achieved by: 85 + 1. First pass: Create all nodes with empty parent lists, store in Hashtbl 86 + 2. Second pass: Process in reverse order, look up parents from Hashtbl, update nodes 87 + *) 88 + let commits_to_nodes (commits : jj_commit list) : Render_jj_graph.node list = 89 + (* First pass: create all nodes without parents and populate hashtable *) 90 + let node_tbl : (string, Render_jj_graph.node) Hashtbl.t = 91 + Hashtbl.create (List.length commits) 92 + in 93 + commits 94 + |> List.iter (fun jj_commit -> 95 + let n : Render_jj_graph.node = 96 + { 97 + parents = [] (* populated in second pass *) 98 + ; creation_time = Int64.of_int 0 99 + ; working_copy = jj_commit.working_copy 100 + ; immutable = jj_commit.immutable 101 + ; wip = jj_commit.wip 102 + ; change_id = jj_commit.change_id 103 + ; commit_id = jj_commit.commit_id 104 + ; description = jj_commit.description 105 + ; bookmarks = jj_commit.bookmarks 106 + ; author_email = jj_commit.author.email 107 + ; author_timestamp = jj_commit.author.timestamp 108 + ; empty = jj_commit.empty 109 + ; hidden = jj_commit.hidden 110 + ; divergent = jj_commit.divergent 111 + ; is_preview = false 112 + } 113 + in 114 + Hashtbl.add node_tbl jj_commit.commit_id n); 115 + (* Second pass: link up parents in reverse order (so parents are resolved before children). 116 + We process in reverse so that when we look up a parent, it's already been updated with 117 + its own parents. Then we update the hashtable with the complete node. 118 + 119 + If a parent is missing (not in the fetched commit list), we create an elided node 120 + to represent it. This allows rendering graphs with incomplete history. *) 121 + let elided_nodes = ref [] in 122 + let rev_commits = List.rev commits in 123 + rev_commits 124 + |> List.iter (fun jj_commit -> 125 + let parents = 126 + jj_commit.parents 127 + |> List.map (fun parent_id -> 128 + match Hashtbl.find_opt node_tbl parent_id with 129 + | Some p -> 130 + p 131 + | None -> 132 + let elided = Render_jj_graph.make_elided_node () in 133 + Hashtbl.add node_tbl parent_id elided; 134 + elided_nodes := elided :: !elided_nodes; 135 + elided) 136 + in 137 + let node = Hashtbl.find node_tbl jj_commit.commit_id in 138 + let updated_node = { node with parents } in 139 + Hashtbl.replace node_tbl jj_commit.commit_id updated_node); 140 + let base_nodes = 141 + commits |> List.map (fun jj_commit -> Hashtbl.find node_tbl jj_commit.commit_id) 142 + in 143 + base_nodes 144 + ;;
+290
jj_tui/lib/jj_json_tests.ml
··· 1 + open Jj_json 2 + 3 + let%expect_test "parse_valid_jsonl" = 4 + let input = 5 + {|{"commit_id":"abc123","parents":[],"change_id":"xyz","description":"First commit","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 6 + {"commit_id":"def456","parents":["abc123"],"change_id":"uvw","description":"Second commit","working_copy":true,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":["main"],"author":{"email":"test@example.com","timestamp":"2024-01-02"}}|} 7 + in 8 + (match parse_jj_log_output input with 9 + | Ok commits -> 10 + Printf.printf "Parsed %d commits\n" (List.length commits); 11 + List.iter 12 + (fun (c : jj_commit) -> 13 + Printf.printf 14 + "Commit: %s, Parents: [%s]\n" 15 + c.commit_id 16 + (String.concat ";" c.parents)) 17 + commits 18 + | Error msg -> 19 + Printf.printf "Error: %s\n" msg); 20 + [%expect 21 + {| 22 + Parsed 2 commits 23 + Commit: abc123, Parents: [] 24 + Commit: def456, Parents: [abc123] 25 + |}] 26 + ;; 27 + 28 + let%expect_test "parse_root_commit" = 29 + let input = 30 + {|{"commit_id":"root","parents":[],"change_id":"xyz","description":"Root","working_copy":false,"immutable":true,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 31 + in 32 + (match parse_jj_log_output input with 33 + | Ok commits -> 34 + let (c : jj_commit) = List.hd commits in 35 + Printf.printf "Root commit: %s, Parents: %d\n" c.commit_id (List.length c.parents) 36 + | Error msg -> 37 + Printf.printf "Error: %s\n" msg); 38 + [%expect 39 + {| 40 + Root commit: root, Parents: 0 41 + |}] 42 + ;; 43 + 44 + let%expect_test "commits_to_nodes_parent_linking" = 45 + let input = 46 + {|{"commit_id":"parent","parents":[],"change_id":"p","description":"Parent","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 47 + {"commit_id":"child","parents":["parent"],"change_id":"c","description":"Child","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}}|} 48 + in 49 + (match parse_jj_log_output input with 50 + | Ok commits -> 51 + let nodes = commits_to_nodes commits in 52 + Printf.printf "Converted %d nodes\n" (List.length nodes); 53 + nodes 54 + |> List.iter (fun (n : Render_jj_graph.node) -> 55 + Printf.printf "Node: %s, Parents: %d\n" n.commit_id (List.length n.parents)); 56 + let child_node = List.nth nodes 1 in 57 + let parent_node = List.nth nodes 0 in 58 + let child_parent = List.hd child_node.parents in 59 + Printf.printf "Parent reference correct: %b\n" (child_parent == parent_node) 60 + | Error msg -> 61 + Printf.printf "Error: %s\n" msg); 62 + [%expect 63 + {| 64 + Converted 2 nodes 65 + Node: parent, Parents: 0 66 + Node: child, Parents: 1 67 + Parent reference correct: false 68 + |}] 69 + ;; 70 + 71 + let%expect_test "parse_multiple_parents" = 72 + let input = 73 + {|{"commit_id":"parent1","parents":[],"change_id":"p1","description":"Parent 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 74 + {"commit_id":"parent2","parents":[],"change_id":"p2","description":"Parent 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 75 + {"commit_id":"merge","parents":["parent1","parent2"],"change_id":"m","description":"Merge commit","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 76 + in 77 + (match parse_jj_log_output input with 78 + | Ok commits -> 79 + let nodes = commits_to_nodes commits in 80 + let (merge_node : Render_jj_graph.node) = List.nth nodes 2 in 81 + Printf.printf 82 + "Merge node: %s, Parents: %d\n" 83 + merge_node.commit_id 84 + (List.length merge_node.parents); 85 + let parent1_node = List.nth nodes 0 in 86 + let parent2_node = List.nth nodes 1 in 87 + let merge_parent1 = List.nth merge_node.parents 0 in 88 + let merge_parent2 = List.nth merge_node.parents 1 in 89 + Printf.printf "First parent correct: %b\n" (merge_parent1 == parent1_node); 90 + Printf.printf "Second parent correct: %b\n" (merge_parent2 == parent2_node) 91 + | Error msg -> 92 + Printf.printf "Error: %s\n" msg); 93 + [%expect 94 + {| 95 + Merge node: merge, Parents: 2 96 + First parent correct: false 97 + Second parent correct: false 98 + |}] 99 + ;; 100 + 101 + let%expect_test "parse_commit_with_bookmarks" = 102 + let input = 103 + {|{"commit_id":"abc123","parents":[],"change_id":"xyz","description":"Commit with bookmarks","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":["main","feature"],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 104 + in 105 + (match parse_jj_log_output input with 106 + | Ok commits -> 107 + let (c : jj_commit) = List.hd commits in 108 + Printf.printf 109 + "Commit: %s, Bookmarks: [%s]\n" 110 + c.commit_id 111 + (String.concat ";" c.bookmarks) 112 + | Error msg -> 113 + Printf.printf "Error: %s\n" msg); 114 + [%expect 115 + {| 116 + Commit: abc123, Bookmarks: [main;feature] 117 + |}] 118 + ;; 119 + 120 + let%expect_test "parse_wip_commit" = 121 + let input = 122 + {|{"commit_id":"wip123","parents":[],"change_id":"xyz","description":"wip: work in progress","working_copy":true,"immutable":false,"wip":true,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 123 + in 124 + (match parse_jj_log_output input with 125 + | Ok commits -> 126 + let (c : jj_commit) = List.hd commits in 127 + Printf.printf 128 + "Commit: %s, WIP: %b, Working copy: %b\n" 129 + c.commit_id 130 + c.wip 131 + c.working_copy 132 + | Error msg -> 133 + Printf.printf "Error: %s\n" msg); 134 + [%expect 135 + {| 136 + Commit: wip123, WIP: true, Working copy: true 137 + |}] 138 + ;; 139 + 140 + let%expect_test "parse_invalid_json" = 141 + let input = {|{"commit_id":"abc123","parents":[]|} in 142 + (match parse_jj_log_output input with 143 + | Ok _ -> 144 + Printf.printf "Unexpected success\n" 145 + | Error msg -> 146 + Printf.printf "Error occurred: %b\n" (String.length msg > 0)); 147 + [%expect 148 + {| 149 + Error occurred: true 150 + |}] 151 + ;; 152 + 153 + let%expect_test "parse_empty_input" = 154 + let input = "" in 155 + (match parse_jj_log_output input with 156 + | Ok commits -> 157 + Printf.printf "Parsed %d commits\n" (List.length commits) 158 + | Error msg -> 159 + Printf.printf "Error: %s\n" msg); 160 + [%expect 161 + {| 162 + Parsed 0 commits 163 + |}] 164 + ;; 165 + 166 + let%expect_test "commits_to_nodes_preserves_order" = 167 + let input = 168 + {|{"commit_id":"first","parents":[],"change_id":"f","description":"First","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 169 + {"commit_id":"second","parents":["first"],"change_id":"s","description":"Second","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 170 + {"commit_id":"third","parents":["second"],"change_id":"t","description":"Third","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 171 + in 172 + (match parse_jj_log_output input with 173 + | Ok commits -> 174 + let nodes = commits_to_nodes commits in 175 + Printf.printf "Order preserved: "; 176 + nodes 177 + |> List.iter (fun (n : Render_jj_graph.node) -> Printf.printf "%s " n.commit_id); 178 + Printf.printf "\n" 179 + | Error msg -> 180 + Printf.printf "Error: %s\n" msg); 181 + [%expect 182 + {| 183 + Order preserved: first second third 184 + |}] 185 + ;; 186 + 187 + let%expect_test "commits_to_nodes_copies_fields" = 188 + let input = 189 + {|{"commit_id":"test","parents":[],"change_id":"xyz","description":"Test commit","working_copy":true,"immutable":true,"wip":true,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 190 + in 191 + (match parse_jj_log_output input with 192 + | Ok commits -> 193 + let nodes = commits_to_nodes commits in 194 + let (n : Render_jj_graph.node) = List.hd nodes in 195 + Printf.printf 196 + "Node fields - commit_id: %s, change_id: %s, working_copy: %b, immutable: %b, \ 197 + wip: %b\n" 198 + n.commit_id 199 + n.change_id 200 + n.working_copy 201 + n.immutable 202 + n.wip 203 + | Error msg -> 204 + Printf.printf "Error: %s\n" msg); 205 + [%expect 206 + {| 207 + Node fields - commit_id: test, change_id: xyz, working_copy: true, immutable: true, wip: true 208 + |}] 209 + ;; 210 + 211 + let%expect_test "commits_to_nodes_missing_parent_creates_elided" = 212 + let input = 213 + {|{"commit_id":"child","parents":["missing_parent"],"change_id":"c","description":"Child with missing parent","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}}|} 214 + in 215 + (match parse_jj_log_output input with 216 + | Ok commits -> 217 + let nodes = commits_to_nodes commits in 218 + Printf.printf "Total nodes: %d\n" (List.length nodes); 219 + let child_node = List.nth nodes 0 in 220 + Printf.printf 221 + "Child node: %s, Parents: %d\n" 222 + child_node.commit_id 223 + (List.length child_node.parents); 224 + let parent = List.hd child_node.parents in 225 + Printf.printf "Parent is elided: %b\n" (Render_jj_graph.is_elided parent); 226 + Printf.printf "Parent commit_id: %s\n" parent.commit_id 227 + | Error msg -> 228 + Printf.printf "Error: %s\n" msg); 229 + [%expect 230 + {| 231 + Total nodes: 1 232 + Child node: child, Parents: 1 233 + Parent is elided: true 234 + Parent commit_id: ~ELIDED~ 235 + |}] 236 + ;; 237 + 238 + let%expect_test "commits_to_nodes_multiple_children_same_missing_parent" = 239 + let input = 240 + {|{"commit_id":"child1","parents":["missing_parent"],"change_id":"c1","description":"Child 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 241 + {"commit_id":"child2","parents":["missing_parent"],"change_id":"c2","description":"Child 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 242 + in 243 + (match parse_jj_log_output input with 244 + | Ok commits -> 245 + let nodes = commits_to_nodes commits in 246 + Printf.printf "Total nodes: %d\n" (List.length nodes); 247 + let child1 = List.nth nodes 0 in 248 + let child2 = List.nth nodes 1 in 249 + let parent1 = List.hd child1.parents in 250 + let parent2 = List.hd child2.parents in 251 + Printf.printf 252 + "Both parents are elided: %b\n" 253 + (Render_jj_graph.is_elided parent1 && Render_jj_graph.is_elided parent2); 254 + Printf.printf "Same parent object (physical equality): %b\n" (parent1 == parent2) 255 + | Error msg -> 256 + Printf.printf "Error: %s\n" msg); 257 + [%expect 258 + {| 259 + Total nodes: 2 260 + Both parents are elided: true 261 + Same parent object (physical equality): true 262 + |}] 263 + ;; 264 + 265 + let%expect_test "commits_to_nodes_multiple_children_same_missing_parent" = 266 + let input = 267 + {|{"commit_id":"child1","parents":["missing_parent"],"change_id":"c1","description":"Child 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 268 + {"commit_id":"child2","parents":["missing_parent"],"change_id":"c2","description":"Child 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 269 + in 270 + (match parse_jj_log_output input with 271 + | Ok commits -> 272 + let nodes = commits_to_nodes commits in 273 + Printf.printf "Converted %d nodes\n" (List.length nodes); 274 + let child1 = List.nth nodes 0 in 275 + let child2 = List.nth nodes 1 in 276 + let parent1 = List.hd child1.parents in 277 + let parent2 = List.hd child2.parents in 278 + Printf.printf 279 + "Both parents are elided: %b\n" 280 + (Render_jj_graph.is_elided parent1 && Render_jj_graph.is_elided parent2); 281 + Printf.printf "Same parent object (physical equality): %b\n" (parent1 == parent2) 282 + | Error msg -> 283 + Printf.printf "Error: %s\n" msg); 284 + [%expect 285 + {| 286 + Converted 2 nodes 287 + Both parents are elided: true 288 + Same parent object (physical equality): true 289 + |}] 290 + ;;
+52 -12
jj_tui/lib/process_wrappers.ml
··· 1 1 (** Collection of JJ specific widgets*) 2 - 3 - open Notty 4 - open Nottui 5 - open Lwd_infix 6 2 open! Util 3 + 7 4 open Process 8 5 open Logging 9 6 open Picos_std_structured ··· 42 39 str 43 40 |> Re.split_full 44 41 (Re.Pcre.regexp 45 - ~flags:[ Re.Pcre.(`MULTILINE) ] 42 + ~flags:[ `MULTILINE ] 46 43 {|(^.*?)\$\$--START--\$\$\|(.+?)\|(.+?)\|(.+?)\|(.+?)\|([\s\S]*?)\$\$--END--\$\$\n?|}) 47 44 in 48 45 let selectable_count = ref 0 in ··· 80 77 change_id 81 78 commit_id 82 79 (Re.Group.get selectable 6 |> remove_ansi)]; 83 - let rev = { commit_id; change_id; divergent } in 80 + let _rev = { commit_id; change_id; divergent } in 84 81 let id = 85 82 if divergent || hidden then Duplicate commit_id else Unique change_id 86 83 in ··· 95 92 ([], []) 96 93 in 97 94 let graph = 98 - (if !selectable_count >= (limit) 95 + (if !selectable_count >= limit 99 96 then ( 100 - [%log debug "limit: %d selectable: %d" limit !selectable_count]; 97 + [%log debug "limit: %d selectable: %d" limit !selectable_count]; 101 98 let txt = 102 99 Printf.sprintf 103 - "\nHit limit of %d items.\nIncrease limit in config or make your revset more \ 104 - precise\n" 100 + "\n\ 101 + Hit limit of %d items.\n\ 102 + Increase limit in config or make your revset more precise\n" 105 103 limit 106 104 in 107 105 `Filler txt :: graph_rev) ··· 150 148 151 149 let get_graph_info node_template revset_arg limit = 152 150 let output = 153 - jj_no_log ([ "log"; "-T"; graph_info_template node_template;"--limit"; limit |>string_of_int] @ revset_arg) 151 + jj_no_log 152 + ([ 153 + "log" 154 + ; "-T" 155 + ; graph_info_template node_template 156 + ; "--limit" 157 + ; limit |> string_of_int 158 + ] 159 + @ revset_arg) 154 160 in 155 161 output |> find_selectable_from_graph limit 156 162 ;; ··· 167 173 let graph, revs = Promise.await graph in 168 174 graph, revs |> Array.of_list 169 175 ;; 176 + 177 + (** Fetch graph data as JSON and parse into commits *) 178 + let get_graph_json ?revset limit = 179 + let args = 180 + [ 181 + "log" 182 + ; "--no-graph" 183 + ; "-T" 184 + ; Jj_json.json_log_template 185 + ; "--limit" 186 + ; string_of_int limit 187 + ] 188 + in 189 + let args = match revset with Some r -> args @ [ "-r"; r ] | None -> args in 190 + let output = jj_no_log args ~color:false in 191 + match Jj_json.parse_jj_log_output output with 192 + | Ok commits -> 193 + commits 194 + | Error msg -> 195 + failwith (Printf.sprintf "Failed to parse jj log JSON: %s" msg) 196 + ;; 197 + 198 + (** Fetch and convert to renderer nodes *) 199 + let get_graph_nodes ?revset limit = 200 + let commits = get_graph_json ?revset limit in 201 + let nodes = Jj_json.commits_to_nodes commits in 202 + let rev_ids = 203 + commits 204 + |> List.map (fun (c : Jj_json.jj_commit) -> 205 + if c.divergent || c.hidden then Duplicate c.commit_id else Unique c.change_id) 206 + |> Array.of_list 207 + in 208 + nodes, rev_ids 209 + ;; 170 210 end 171 211 172 212 (*========Tests======*) ··· 216 256 ;; 217 257 218 258 let%expect_test "revs_graph_parsing" = 219 - let graph, ids = find_selectable_from_graph 2000 test_data_3 in 259 + let graph, ids = find_selectable_from_graph 2000 test_data_3 in 220 260 let ids = ids |> Array.of_list in 221 261 let ids_idx = ref 0 in 222 262 graph
+1021
jj_tui/lib/render_jj_graph.ml
··· 1 + (** 2 + `render_jj_graph.ml` 3 + 4 + This module is a small, self-contained experiment for rendering jj-style commit graphs 5 + in a terminal. The renderer is **lane-based**: 6 + 7 + The tests in `render_jj_graph_tests.ml` are "golden" tests: they assert the exact glyph 8 + output. When changing the algorithm, prefer updating the algorithm to match the golden 9 + outputs, not the other way around. 10 + *) 11 + 12 + (** Glyph constants used by the renderer. *) 13 + module P = struct 14 + let v = Util.make_uchar "│" 15 + let vr = Util.make_uchar "├" 16 + let vl = Util.make_uchar "┤" 17 + let t = Util.make_uchar "┬" 18 + let cross = Util.make_uchar "┼" 19 + let h = Util.make_uchar "─" 20 + let b = Util.make_uchar "┴" 21 + 22 + (* elbow down right *) 23 + let edr = Util.make_uchar "╮" 24 + let eur = Util.make_uchar "╯" 25 + let edl = Util.make_uchar "╭" 26 + let eul = Util.make_uchar "╰" 27 + let sp = Util.make_uchar " " 28 + let ancestor = Util.make_uchar "╷" 29 + let term = Util.make_uchar "~" 30 + 31 + module Node = struct 32 + let normal = Util.make_uchar "○" 33 + let working_copy = Util.make_uchar "@" 34 + let wip = Util.make_uchar "◌" 35 + let immutable = Util.make_uchar "◆" 36 + end 37 + end 38 + 39 + (** Node type for the graph. *) 40 + type node = { 41 + parents : node list 42 + ; creation_time : int64 43 + ; working_copy : bool 44 + ; immutable : bool 45 + ; wip : bool 46 + ; change_id : string 47 + ; commit_id : string 48 + ; description : string 49 + ; bookmarks : string list 50 + ; author_email : string 51 + ; author_timestamp : string 52 + ; empty : bool 53 + ; hidden : bool 54 + ; divergent : bool 55 + ; is_preview : bool 56 + } 57 + 58 + (** Special marker for elided nodes *) 59 + let elided_marker = "~ELIDED~" 60 + 61 + (** Create a special node representing an elided section *) 62 + let make_elided_node () : node = 63 + { 64 + parents = [] 65 + ; creation_time = Int64.zero 66 + ; working_copy = false 67 + ; immutable = false 68 + ; wip = false 69 + ; change_id = elided_marker 70 + ; commit_id = elided_marker 71 + ; description = "(elided revisions)" 72 + ; bookmarks = [] 73 + ; author_email = "" 74 + ; author_timestamp = "" 75 + ; empty = false 76 + ; hidden = true 77 + ; divergent = false 78 + ; is_preview = false 79 + } 80 + ;; 81 + 82 + (** Check if a node represents an elided section *) 83 + let is_elided (n : node) : bool = n.commit_id = elided_marker 84 + 85 + (* ============================================================================ 86 + Preview Node Support 87 + ============================================================================ *) 88 + 89 + (** Create a preview node with a label. 90 + Preview nodes are used to visualize where commits would land during 91 + rebase/move operations. *) 92 + let make_preview_node ~label ?target_commit_id () : node = 93 + { 94 + parents = [] 95 + ; creation_time = Int64.zero 96 + ; working_copy = false 97 + ; immutable = false 98 + ; wip = false 99 + ; change_id = Printf.sprintf "preview:%s" label 100 + ; commit_id = 101 + (match target_commit_id with 102 + | Some id -> 103 + Printf.sprintf "preview:%s:%s" label id 104 + | None -> 105 + Printf.sprintf "preview:%s" label) 106 + ; description = label 107 + ; bookmarks = [] 108 + ; author_email = "" 109 + ; author_timestamp = "" 110 + ; empty = false 111 + ; hidden = false 112 + ; divergent = false 113 + ; is_preview = true 114 + } 115 + ;; 116 + 117 + (** Insert a preview node after the specified commit. 118 + The preview node will be inserted as a child of the target commit. *) 119 + let insert_preview_after ~nodes ~after_commit_id ~preview : node list = 120 + let rec insert acc = function 121 + | [] -> 122 + List.rev acc 123 + | node :: rest when node.commit_id = after_commit_id -> 124 + (* Found the target node - insert preview after it *) 125 + let preview_with_parent = { preview with parents = [ node ] } in 126 + List.rev_append acc (node :: preview_with_parent :: rest) 127 + | node :: rest -> 128 + insert (node :: acc) rest 129 + in 130 + insert [] nodes 131 + ;; 132 + 133 + (** Insert a preview node before the specified commit. 134 + The preview node will be inserted as a parent of the target commit, 135 + and will inherit the target's parents. *) 136 + let insert_preview_before ~nodes ~before_commit_id ~preview : node list = 137 + let rec insert acc = function 138 + | [] -> 139 + List.rev acc 140 + | node :: rest when node.commit_id = before_commit_id -> 141 + (* Found the target node - insert preview before it *) 142 + let preview_with_parents = { preview with parents = node.parents } in 143 + let node_with_preview_parent = { node with parents = [ preview_with_parents ] } in 144 + List.rev_append acc (preview_with_parents :: node_with_preview_parent :: rest) 145 + | node :: rest -> 146 + insert (node :: acc) rest 147 + in 148 + insert [] nodes 149 + ;; 150 + 151 + (** Row type classification for structured output *) 152 + type row_type = 153 + | NodeRow (** The main row with the node glyph *) 154 + | LinkRow (** Merge/fork connector lines *) 155 + | PadRow (** Padding/continuation lines *) 156 + | TermRow (** Termination lines with ~ *) 157 + 158 + (** Structured output for UI integration *) 159 + type graph_row_output = { 160 + graph_chars : string (** The graph prefix like "○ " or "├─╮" *) 161 + ; node : node (** The node this row represents *) 162 + ; row_type : row_type (** What kind of row this is *) 163 + } 164 + 165 + (** Column state - tracks what occupies each graph column *) 166 + type column = 167 + | Empty 168 + | Blocked 169 + | Reserved of node 170 + | Ancestor of node 171 + | Parent of node 172 + 173 + (** Ancestor type for parent specifications *) 174 + type ancestor_type = 175 + | A_Ancestor of node 176 + | A_Parent of node 177 + | A_Anonymous 178 + 179 + (** State for the renderer *) 180 + type state = { 181 + depth : int 182 + ; columns : column array 183 + ; pending_joins : (int * int) list 184 + } 185 + 186 + (** Node line entry - what to render in node row for each column *) 187 + type node_line_entry = 188 + | NL_Blank 189 + | NL_Ancestor 190 + | NL_Parent 191 + | NL_Node 192 + 193 + (** Pad line entry - what to render in padding rows *) 194 + type pad_line_entry = 195 + | PL_Blank 196 + | PL_Ancestor 197 + | PL_Parent 198 + 199 + (** LinkLine module - bitflags for link row rendering *) 200 + module LinkLine = struct 201 + type t = int 202 + 203 + let empty = 0 204 + let horiz_parent = 0x0001 205 + let horiz_ancestor = 0x0002 206 + let vert_parent = 0x0004 207 + let vert_ancestor = 0x0008 208 + let left_fork_parent = 0x0010 209 + let left_fork_ancestor = 0x0020 210 + let right_fork_parent = 0x0040 211 + let right_fork_ancestor = 0x0080 212 + let left_merge_parent = 0x0100 213 + let left_merge_ancestor = 0x0200 214 + let right_merge_parent = 0x0400 215 + let right_merge_ancestor = 0x0800 216 + let child = 0x1000 217 + 218 + (* Compound flags *) 219 + let horizontal = horiz_parent lor horiz_ancestor 220 + let vertical = vert_parent lor vert_ancestor 221 + let left_fork = left_fork_parent lor left_fork_ancestor 222 + let right_fork = right_fork_parent lor right_fork_ancestor 223 + let left_merge = left_merge_parent lor left_merge_ancestor 224 + let right_merge = right_merge_parent lor right_merge_ancestor 225 + let any_merge = left_merge lor right_merge 226 + let any_fork = left_fork lor right_fork 227 + let ( lor ) = ( lor ) 228 + let intersects a b = a land b <> 0 229 + let contains a b = a land b = b 230 + end 231 + 232 + (** Graph row - intermediate representation for one node *) 233 + type graph_row = { 234 + row_node : node 235 + ; glyph : Uchar.t 236 + ; message : string 237 + ; merge : bool 238 + ; node_line : node_line_entry array 239 + ; link_line : LinkLine.t array option 240 + ; term_line : bool array option 241 + ; pad_lines : pad_line_entry array 242 + } 243 + 244 + (* ============================================================================ 245 + Column utilities (Rust ColumnsExt equivalent) 246 + ============================================================================ *) 247 + 248 + let column_matches col n = 249 + match col with Empty | Blocked -> false | Reserved o | Ancestor o | Parent o -> o == n 250 + ;; 251 + 252 + let column_variant = function 253 + | Empty -> 254 + 0 255 + | Blocked -> 256 + 1 257 + | Reserved _ -> 258 + 2 259 + | Ancestor _ -> 260 + 3 261 + | Parent _ -> 262 + 4 263 + ;; 264 + 265 + let column_merge a b = if column_variant b > column_variant a then b else a 266 + 267 + let columns_find cols n = 268 + let rec loop i = 269 + if i >= Array.length cols 270 + then None 271 + else if column_matches cols.(i) n 272 + then Some i 273 + else loop (i + 1) 274 + in 275 + loop 0 276 + ;; 277 + 278 + let columns_first_empty cols = 279 + let rec loop i = 280 + if i >= Array.length cols 281 + then None 282 + else (match cols.(i) with Empty -> Some i | _ -> loop (i + 1)) 283 + in 284 + loop 0 285 + ;; 286 + 287 + let columns_find_empty cols ~prefer = 288 + if prefer < Array.length cols 289 + then (match cols.(prefer) with Empty -> Some prefer | _ -> columns_first_empty cols) 290 + else columns_first_empty cols 291 + ;; 292 + 293 + let column_to_node_line = function 294 + | Ancestor _ -> 295 + NL_Ancestor 296 + | Parent _ -> 297 + NL_Parent 298 + | _ -> 299 + NL_Blank 300 + ;; 301 + 302 + let column_to_link_line = function 303 + | Ancestor _ -> 304 + LinkLine.vert_ancestor 305 + | Parent _ -> 306 + LinkLine.vert_parent 307 + | _ -> 308 + LinkLine.empty 309 + ;; 310 + 311 + let column_to_pad_line = function 312 + | Ancestor _ -> 313 + PL_Ancestor 314 + | Parent _ -> 315 + PL_Parent 316 + | _ -> 317 + PL_Blank 318 + ;; 319 + 320 + let ancestor_to_column = function 321 + | A_Ancestor n -> 322 + Ancestor n 323 + | A_Parent n -> 324 + Parent n 325 + | A_Anonymous -> 326 + Blocked 327 + ;; 328 + 329 + let ancestor_id = function 330 + | A_Ancestor n -> 331 + Some n 332 + | A_Parent n -> 333 + Some n 334 + | A_Anonymous -> 335 + None 336 + ;; 337 + 338 + let ancestor_is_direct = function 339 + | A_Ancestor _ -> 340 + false 341 + | A_Parent _ -> 342 + true 343 + | A_Anonymous -> 344 + true 345 + ;; 346 + 347 + let ancestor_to_link_line anc ~direct ~indirect = 348 + if ancestor_is_direct anc then direct else indirect 349 + ;; 350 + 351 + (* Reset columns: Blocked -> Empty, then trim trailing Empty *) 352 + let columns_reset cols = 353 + let len = Array.length cols in 354 + for i = 0 to len - 1 do 355 + match cols.(i) with Blocked -> cols.(i) <- Empty | _ -> () 356 + done; 357 + (* Find last non-empty *) 358 + let rec find_last i = 359 + if i < 0 then 0 else (match cols.(i) with Empty -> find_last (i - 1) | _ -> i + 1) 360 + in 361 + let new_len = find_last (len - 1) in 362 + if new_len < len then Array.sub cols 0 new_len else cols 363 + ;; 364 + 365 + (* ============================================================================ 366 + AncestorColumnBounds - for computing horizontal line ranges 367 + ============================================================================ *) 368 + 369 + type ancestor_bounds = { 370 + target : int 371 + ; min_ancestor : int 372 + ; min_parent : int 373 + ; max_parent : int 374 + ; max_ancestor : int 375 + } 376 + 377 + let compute_bounds parent_columns target = 378 + if List.length parent_columns = 0 379 + then None 380 + else ( 381 + let indices = List.map fst parent_columns in 382 + let min_ancestor = List.fold_left min target indices in 383 + let max_ancestor = List.fold_left max target indices in 384 + let direct_indices = 385 + parent_columns 386 + |> List.filter (fun (_, anc) -> ancestor_is_direct anc) 387 + |> List.map fst 388 + in 389 + let min_parent = 390 + if List.length direct_indices = 0 391 + then target 392 + else min target (List.fold_left min max_int direct_indices) 393 + in 394 + let max_parent = 395 + if List.length direct_indices = 0 396 + then target 397 + else max target (List.fold_left max min_int direct_indices) 398 + in 399 + Some { target; min_ancestor; min_parent; max_parent; max_ancestor }) 400 + ;; 401 + 402 + let bounds_horizontal_line bounds index = 403 + if index = bounds.target 404 + then LinkLine.empty 405 + else if index > bounds.min_parent && index < bounds.max_parent 406 + then LinkLine.horiz_parent 407 + else if index > bounds.min_ancestor && index < bounds.max_ancestor 408 + then LinkLine.horiz_ancestor 409 + else LinkLine.empty 410 + ;; 411 + 412 + (* ============================================================================ 413 + GraphRowRenderer.next_row - core algorithm 414 + ============================================================================ *) 415 + 416 + let next_row ~(columns : column array ref) (n : node) : graph_row = 417 + let parents = 418 + n.parents |> List.map (fun p -> if is_elided p then A_Anonymous else A_Parent p) 419 + (* Elided parents are treated as anonymous to trigger termination lines *) 420 + in 421 + (* Find a column for this node *) 422 + let column = 423 + match columns_find !columns n with 424 + | Some i -> 425 + i 426 + | None -> 427 + (match columns_first_empty !columns with 428 + | Some i -> 429 + i 430 + | None -> 431 + let len = Array.length !columns in 432 + columns := Array.append !columns [| Empty |]; 433 + len) 434 + in 435 + (* Clear the node's column *) 436 + !columns.(column) <- Empty; 437 + (* This row is for a merge if there are multiple parents *) 438 + let merge = List.length parents > 1 in 439 + (* Build initial row arrays from current columns *) 440 + let node_line = Array.map column_to_node_line !columns in 441 + node_line.(column) <- NL_Node; 442 + let link_line = Array.map column_to_link_line !columns in 443 + let term_line = Array.map (fun _ -> false) !columns in 444 + let pad_lines = Array.map column_to_pad_line !columns in 445 + let need_link_line = ref false in 446 + let need_term_line = ref false in 447 + let parent_columns = ref [] in 448 + List.iter 449 + (fun p -> 450 + match ancestor_id p with 451 + | Some parent_node -> 452 + (match columns_find !columns parent_node with 453 + | Some index -> 454 + !columns.(index) <- column_merge !columns.(index) (ancestor_to_column p); 455 + parent_columns := (index, p) :: !parent_columns 456 + | None -> 457 + (match columns_find_empty !columns ~prefer:column with 458 + | Some index -> 459 + !columns.(index) <- column_merge !columns.(index) (ancestor_to_column p); 460 + parent_columns := (index, p) :: !parent_columns 461 + | None -> 462 + let new_idx = Array.length !columns in 463 + columns := Array.append !columns [| ancestor_to_column p |]; 464 + parent_columns := (new_idx, p) :: !parent_columns)) 465 + | None -> 466 + (match columns_find_empty !columns ~prefer:column with 467 + | Some index -> 468 + !columns.(index) <- column_merge !columns.(index) (ancestor_to_column p); 469 + parent_columns := (index, p) :: !parent_columns 470 + | None -> 471 + let new_idx = Array.length !columns in 472 + columns := Array.append !columns [| ancestor_to_column p |]; 473 + parent_columns := (new_idx, p) :: !parent_columns)) 474 + parents; 475 + (* Ensure arrays are long enough for any new columns *) 476 + let cols_len = Array.length !columns in 477 + let extend arr default = 478 + if Array.length arr < cols_len 479 + then ( 480 + let new_arr = Array.make cols_len default in 481 + Array.blit arr 0 new_arr 0 (Array.length arr); 482 + new_arr) 483 + else arr 484 + in 485 + let node_line = extend node_line NL_Blank in 486 + let link_line = extend link_line LinkLine.empty in 487 + let term_line = extend term_line false in 488 + let pad_lines = extend pad_lines PL_Blank in 489 + (* Mark anonymous parents as terminating *) 490 + List.iter 491 + (fun (i, p) -> 492 + match ancestor_id p with 493 + | None -> 494 + term_line.(i) <- true; 495 + need_term_line := true 496 + | Some _ -> 497 + ()) 498 + !parent_columns; 499 + (* Reverse parent_columns to get proper order *) 500 + parent_columns := List.rev !parent_columns; 501 + (* Single parent swap optimization *) 502 + let link_line = 503 + if List.length parents = 1 504 + then ( 505 + match !parent_columns with 506 + | [ (parent_column, _) ] when parent_column > column -> 507 + (* Swap columns *) 508 + let tmp = !columns.(column) in 509 + !columns.(column) <- !columns.(parent_column); 510 + !columns.(parent_column) <- tmp; 511 + (* Update parent_columns *) 512 + let p = snd (List.hd !parent_columns) in 513 + parent_columns := [ column, p ]; 514 + (* Generate link line from this column to old parent column *) 515 + let was_direct = 516 + LinkLine.intersects link_line.(parent_column) LinkLine.vert_parent 517 + in 518 + link_line.(column) 519 + <- LinkLine.( 520 + link_line.(column) 521 + lor if was_direct then right_fork_parent else right_fork_ancestor); 522 + for i = column + 1 to parent_column - 1 do 523 + link_line.(i) 524 + <- LinkLine.( 525 + link_line.(i) lor if was_direct then horiz_parent else horiz_ancestor) 526 + done; 527 + link_line.(parent_column) 528 + <- (if was_direct 529 + then LinkLine.left_merge_parent 530 + else LinkLine.left_merge_ancestor); 531 + need_link_line := true; 532 + (* Pad line for old parent column is now blank *) 533 + pad_lines.(parent_column) <- PL_Blank; 534 + link_line 535 + | _ -> 536 + link_line) 537 + else link_line 538 + in 539 + (* Connect node column to all parent columns *) 540 + (match compute_bounds !parent_columns column with 541 + | Some bounds -> 542 + (* Horizontal line between outermost ancestors *) 543 + for i = bounds.min_ancestor + 1 to bounds.max_ancestor - 1 do 544 + if i <> bounds.target 545 + then ( 546 + link_line.(i) <- LinkLine.(link_line.(i) lor bounds_horizontal_line bounds i); 547 + need_link_line := true) 548 + done; 549 + (* Merge markers on node column *) 550 + if bounds.max_parent > column 551 + then ( 552 + link_line.(column) <- LinkLine.(link_line.(column) lor right_merge_parent); 553 + need_link_line := true) 554 + else if bounds.max_ancestor > column 555 + then ( 556 + link_line.(column) <- LinkLine.(link_line.(column) lor right_merge_ancestor); 557 + need_link_line := true); 558 + if bounds.min_parent < column 559 + then ( 560 + link_line.(column) <- LinkLine.(link_line.(column) lor left_merge_parent); 561 + need_link_line := true) 562 + else if bounds.min_ancestor < column 563 + then ( 564 + link_line.(column) <- LinkLine.(link_line.(column) lor left_merge_ancestor); 565 + need_link_line := true); 566 + (* Fork markers on each parent column *) 567 + List.iter 568 + (fun (i, p) -> 569 + pad_lines.(i) <- column_to_pad_line !columns.(i); 570 + if i < column 571 + then 572 + link_line.(i) 573 + <- LinkLine.( 574 + link_line.(i) 575 + lor ancestor_to_link_line 576 + p 577 + ~direct:right_fork_parent 578 + ~indirect:right_fork_ancestor) 579 + else if i = column 580 + then 581 + link_line.(i) 582 + <- LinkLine.( 583 + link_line.(i) 584 + lor child 585 + lor ancestor_to_link_line p ~direct:vert_parent ~indirect:vert_ancestor) 586 + else 587 + link_line.(i) 588 + <- LinkLine.( 589 + link_line.(i) 590 + lor ancestor_to_link_line 591 + p 592 + ~direct:left_fork_parent 593 + ~indirect:left_fork_ancestor)) 594 + !parent_columns 595 + | None -> 596 + ()); 597 + (* Reset columns *) 598 + columns := columns_reset !columns; 599 + (* Compute glyph for this node *) 600 + let glyph = 601 + if n.working_copy 602 + then P.Node.working_copy 603 + else if n.immutable 604 + then P.Node.immutable 605 + else if n.wip 606 + then P.Node.wip 607 + else P.Node.normal 608 + in 609 + { 610 + row_node = n 611 + ; glyph 612 + ; message = "" 613 + ; merge 614 + ; node_line 615 + ; link_line = (if !need_link_line then Some link_line else None) 616 + ; term_line = (if !need_term_line then Some term_line else None) 617 + ; pad_lines 618 + } 619 + ;; 620 + 621 + (* ============================================================================ 622 + BoxDrawing - glyph selection and string rendering 623 + ============================================================================ *) 624 + 625 + module Glyph = struct 626 + let space = 0 627 + let horizontal = 1 628 + let parent = 2 629 + let ancestor = 3 630 + let merge_left = 4 631 + let merge_right = 5 632 + let merge_both = 6 633 + let fork_left = 7 634 + let fork_right = 8 635 + let fork_both = 9 636 + let join_left = 10 637 + let join_right = 11 638 + let join_both = 12 639 + let termination = 13 640 + end 641 + 642 + (** 2-character glyph strings matching Rust CURVED_GLYPHS. 643 + Second character is "─" if horizontal line continues right, " " otherwise. *) 644 + let glyphs = 645 + [| 646 + " " (* space *) 647 + ; "──" (* horizontal *) 648 + ; "│ " (* parent *) 649 + ; "╷ " (* ancestor *) 650 + ; "╯ " (* merge_left *) 651 + ; "╰─" (* merge_right *) 652 + ; "┴─" (* merge_both *) 653 + ; "╮ " (* fork_left *) 654 + ; "╭─" (* fork_right *) 655 + ; "┬─" (* fork_both *) 656 + ; "┤ " (* join_left *) 657 + ; "├─" (* join_right *) 658 + ; "┼─" (* join_both *) 659 + ; "~ " (* termination *) 660 + |] 661 + ;; 662 + 663 + let pad_line_to_glyph = function 664 + | PL_Parent -> 665 + Glyph.parent 666 + | PL_Ancestor -> 667 + Glyph.ancestor 668 + | PL_Blank -> 669 + Glyph.space 670 + ;; 671 + 672 + let select_link_glyph cur ~merge = 673 + let open LinkLine in 674 + if intersects cur horizontal 675 + then 676 + if intersects cur child 677 + then Glyph.join_both 678 + else if intersects cur any_fork && intersects cur any_merge 679 + then Glyph.join_both 680 + else if intersects cur any_fork && intersects cur vert_parent && not merge 681 + then Glyph.join_both 682 + else if intersects cur any_fork 683 + then Glyph.fork_both 684 + else if intersects cur any_merge 685 + then Glyph.merge_both 686 + else Glyph.horizontal 687 + else if intersects cur vert_parent && not merge 688 + then ( 689 + let left = intersects cur (left_merge lor left_fork) in 690 + let right = intersects cur (right_merge lor right_fork) in 691 + match left, right with 692 + | true, true -> 693 + Glyph.join_both 694 + | true, false -> 695 + Glyph.join_left 696 + | false, true -> 697 + Glyph.join_right 698 + | false, false -> 699 + Glyph.parent) 700 + else if 701 + intersects cur (vert_parent lor vert_ancestor) 702 + && not (intersects cur (left_fork lor right_fork)) 703 + then ( 704 + let left = intersects cur left_merge in 705 + let right = intersects cur right_merge in 706 + match left, right with 707 + | true, true -> 708 + Glyph.join_both 709 + | true, false -> 710 + Glyph.join_left 711 + | false, true -> 712 + Glyph.join_right 713 + | false, false -> 714 + if intersects cur vert_ancestor then Glyph.ancestor else Glyph.parent) 715 + else if intersects cur left_fork && intersects cur (left_merge lor child) 716 + then Glyph.join_left 717 + else if intersects cur right_fork && intersects cur (right_merge lor child) 718 + then Glyph.join_right 719 + else if intersects cur left_merge && intersects cur right_merge 720 + then Glyph.merge_both 721 + else if intersects cur left_fork && intersects cur right_fork 722 + then Glyph.fork_both 723 + else if intersects cur left_fork 724 + then Glyph.fork_left 725 + else if intersects cur left_merge 726 + then Glyph.merge_left 727 + else if intersects cur right_fork 728 + then Glyph.fork_right 729 + else if intersects cur right_merge 730 + then Glyph.merge_right 731 + else Glyph.space 732 + ;; 733 + 734 + let render_row_to_string (row : graph_row) ~extra_pad_line_ref : string = 735 + let buf = Buffer.create 64 in 736 + (match !extra_pad_line_ref with 737 + | Some s -> 738 + Buffer.add_string buf (String.trim s); 739 + Buffer.add_char buf '\n'; 740 + extra_pad_line_ref := None 741 + | None -> 742 + ()); 743 + Array.iter 744 + (fun entry -> 745 + match entry with 746 + | NL_Node -> 747 + Buffer.add_utf_8_uchar buf row.glyph; 748 + Buffer.add_char buf ' ' 749 + | NL_Parent -> 750 + Buffer.add_string buf glyphs.(Glyph.parent) 751 + | NL_Ancestor -> 752 + Buffer.add_string buf glyphs.(Glyph.ancestor) 753 + | NL_Blank -> 754 + Buffer.add_string buf glyphs.(Glyph.space)) 755 + row.node_line; 756 + let node_str = Buffer.contents buf |> String.trim in 757 + Buffer.reset buf; 758 + Buffer.add_string buf node_str; 759 + Buffer.add_char buf '\n'; 760 + (match row.link_line with 761 + | Some link_row -> 762 + let link_buf = Buffer.create 64 in 763 + Array.iter 764 + (fun cur -> 765 + let glyph_idx = select_link_glyph cur ~merge:row.merge in 766 + Buffer.add_string link_buf glyphs.(glyph_idx)) 767 + link_row; 768 + let link_str = Buffer.contents link_buf |> String.trim in 769 + Buffer.add_string buf link_str; 770 + Buffer.add_char buf '\n' 771 + | None -> 772 + ()); 773 + let need_extra_pad = ref false in 774 + (match row.term_line with 775 + | Some term_row -> 776 + let term_buf1 = Buffer.create 64 in 777 + Array.iteri 778 + (fun i term -> 779 + if term 780 + then Buffer.add_string term_buf1 glyphs.(Glyph.parent) 781 + else ( 782 + let pad_glyph = pad_line_to_glyph row.pad_lines.(i) in 783 + Buffer.add_string term_buf1 glyphs.(pad_glyph))) 784 + term_row; 785 + Buffer.add_string buf (Buffer.contents term_buf1 |> String.trim); 786 + Buffer.add_char buf '\n'; 787 + let term_buf2 = Buffer.create 64 in 788 + Array.iteri 789 + (fun i term -> 790 + if term 791 + then Buffer.add_string term_buf2 glyphs.(Glyph.termination) 792 + else ( 793 + let pad_glyph = pad_line_to_glyph row.pad_lines.(i) in 794 + Buffer.add_string term_buf2 glyphs.(pad_glyph))) 795 + term_row; 796 + Buffer.add_string buf (Buffer.contents term_buf2 |> String.trim); 797 + Buffer.add_char buf '\n'; 798 + need_extra_pad := true 799 + | None -> 800 + ()); 801 + let pad_buf = Buffer.create 64 in 802 + Array.iter 803 + (fun entry -> 804 + let glyph_idx = pad_line_to_glyph entry in 805 + Buffer.add_string pad_buf glyphs.(glyph_idx)) 806 + row.pad_lines; 807 + let base_pad_line = Buffer.contents pad_buf in 808 + if !need_extra_pad then extra_pad_line_ref := Some base_pad_line; 809 + Buffer.contents buf 810 + ;; 811 + 812 + (* ============================================================================ 813 + Public API - render_nodes_to_string 814 + ============================================================================ *) 815 + 816 + let render_nodes_to_string ?(info_rows = fun _ -> 0) (_state : state) (nodes : node list) 817 + : string 818 + = 819 + let columns = ref [||] in 820 + let extra_pad_line_ref = ref None in 821 + let buf = Buffer.create 256 in 822 + List.iter 823 + (fun n -> 824 + let row = next_row ~columns n in 825 + let row_str = render_row_to_string row ~extra_pad_line_ref in 826 + Buffer.add_string buf row_str; 827 + let extra_rows = info_rows n in 828 + for _ = 1 to extra_rows do 829 + let pad_buf = Buffer.create 64 in 830 + Array.iter 831 + (fun col -> 832 + let glyph_idx = pad_line_to_glyph (column_to_pad_line col) in 833 + Buffer.add_string pad_buf glyphs.(glyph_idx)) 834 + !columns; 835 + Buffer.add_string buf (Buffer.contents pad_buf |> String.trim); 836 + Buffer.add_char buf '\n' 837 + done) 838 + nodes; 839 + (* Final extra pad line if pending *) 840 + (match !extra_pad_line_ref with 841 + | Some s -> 842 + Buffer.add_string buf (String.trim s); 843 + Buffer.add_char buf '\n' 844 + | None -> 845 + ()); 846 + Buffer.contents buf 847 + ;; 848 + 849 + (* ============================================================================ 850 + Public API - render_nodes_structured 851 + ============================================================================ *) 852 + 853 + let classify_row_type (line : string) : row_type = 854 + let contains_str s substr = 855 + try 856 + let _ = Str.search_forward (Str.regexp_string substr) s 0 in 857 + true 858 + with 859 + | Not_found -> 860 + false 861 + in 862 + let has_node_glyph = 863 + contains_str line "○" 864 + || contains_str line "@" 865 + || contains_str line "◌" 866 + || contains_str line "◆" 867 + in 868 + let has_term = contains_str line "~" in 869 + let has_merge_fork = 870 + contains_str line "├" 871 + || contains_str line "╮" 872 + || contains_str line "╯" 873 + || contains_str line "╰" 874 + || contains_str line "┬" 875 + || contains_str line "┴" 876 + || contains_str line "┼" 877 + in 878 + if has_node_glyph 879 + then NodeRow 880 + else if has_term 881 + then TermRow 882 + else if has_merge_fork 883 + then LinkRow 884 + else PadRow 885 + ;; 886 + 887 + (** Render nodes to structured output for UI integration *) 888 + let render_nodes_structured 889 + ?(info_lines = fun _ -> 0) 890 + (_state : state) 891 + (nodes : node list) : graph_row_output list 892 + = 893 + let columns = ref [||] in 894 + let extra_pad_line_ref = ref None in 895 + let result = ref [] in 896 + List.iter 897 + (fun n -> 898 + let row = next_row ~columns n in 899 + (match !extra_pad_line_ref with 900 + | Some s -> 901 + let trimmed = String.trim s in 902 + result 903 + := { graph_chars = trimmed; node = n; row_type = classify_row_type trimmed } 904 + :: !result; 905 + extra_pad_line_ref := None 906 + | None -> 907 + ()); 908 + let node_buf = Buffer.create 64 in 909 + Array.iter 910 + (fun entry -> 911 + match entry with 912 + | NL_Node -> 913 + Buffer.add_utf_8_uchar node_buf row.glyph; 914 + Buffer.add_char node_buf ' ' 915 + | NL_Parent -> 916 + Buffer.add_string node_buf glyphs.(Glyph.parent) 917 + | NL_Ancestor -> 918 + Buffer.add_string node_buf glyphs.(Glyph.ancestor) 919 + | NL_Blank -> 920 + Buffer.add_string node_buf glyphs.(Glyph.space)) 921 + row.node_line; 922 + let node_str = Buffer.contents node_buf |> String.trim in 923 + result 924 + := { graph_chars = node_str; node = n; row_type = classify_row_type node_str } 925 + :: !result; 926 + (match row.link_line with 927 + | Some link_row -> 928 + let link_buf = Buffer.create 64 in 929 + Array.iter 930 + (fun cur -> 931 + let glyph_idx = select_link_glyph cur ~merge:row.merge in 932 + Buffer.add_string link_buf glyphs.(glyph_idx)) 933 + link_row; 934 + let link_str = Buffer.contents link_buf |> String.trim in 935 + result 936 + := { graph_chars = link_str; node = n; row_type = classify_row_type link_str } 937 + :: !result 938 + | None -> 939 + ()); 940 + let need_extra_pad = ref false in 941 + (match row.term_line with 942 + | Some term_row -> 943 + let term_buf1 = Buffer.create 64 in 944 + Array.iteri 945 + (fun i term -> 946 + if term 947 + then Buffer.add_string term_buf1 glyphs.(Glyph.parent) 948 + else ( 949 + let pad_glyph = pad_line_to_glyph row.pad_lines.(i) in 950 + Buffer.add_string term_buf1 glyphs.(pad_glyph))) 951 + term_row; 952 + let term_str1 = Buffer.contents term_buf1 |> String.trim in 953 + result 954 + := { graph_chars = term_str1; node = n; row_type = classify_row_type term_str1 } 955 + :: !result; 956 + let term_buf2 = Buffer.create 64 in 957 + Array.iteri 958 + (fun i term -> 959 + if term 960 + then Buffer.add_string term_buf2 glyphs.(Glyph.termination) 961 + else ( 962 + let pad_glyph = pad_line_to_glyph row.pad_lines.(i) in 963 + Buffer.add_string term_buf2 glyphs.(pad_glyph))) 964 + term_row; 965 + let term_str2 = Buffer.contents term_buf2 |> String.trim in 966 + result 967 + := { graph_chars = term_str2; node = n; row_type = classify_row_type term_str2 } 968 + :: !result; 969 + need_extra_pad := true 970 + | None -> 971 + ()); 972 + let pad_buf = Buffer.create 64 in 973 + Array.iter 974 + (fun entry -> 975 + let glyph_idx = pad_line_to_glyph entry in 976 + Buffer.add_string pad_buf glyphs.(glyph_idx)) 977 + row.pad_lines; 978 + let base_pad_line = Buffer.contents pad_buf in 979 + if !need_extra_pad then extra_pad_line_ref := Some base_pad_line; 980 + let extra_rows = info_lines n in 981 + for _ = 1 to extra_rows do 982 + let info_pad_buf = Buffer.create 64 in 983 + Array.iter 984 + (fun col -> 985 + let glyph_idx = pad_line_to_glyph (column_to_pad_line col) in 986 + Buffer.add_string info_pad_buf glyphs.(glyph_idx)) 987 + !columns; 988 + let info_pad_str = Buffer.contents info_pad_buf |> String.trim in 989 + result 990 + := { 991 + graph_chars = info_pad_str 992 + ; node = n 993 + ; row_type = classify_row_type info_pad_str 994 + } 995 + :: !result 996 + done) 997 + nodes; 998 + (match !extra_pad_line_ref with 999 + | Some s -> 1000 + let trimmed = String.trim s in 1001 + let last_node = List.hd (List.rev nodes) in 1002 + result 1003 + := { graph_chars = trimmed; node = last_node; row_type = classify_row_type trimmed } 1004 + :: !result 1005 + | None -> 1006 + ()); 1007 + List.rev !result 1008 + ;; 1009 + 1010 + (* ============================================================================ 1011 + Public API - render_nodes_to_ui (Notty output) 1012 + ============================================================================ *) 1013 + 1014 + let render_nodes_to_ui ?(info_rows = fun _ -> 0) (state : state) (nodes : node list) : 1015 + Notty.image 1016 + = 1017 + let str = render_nodes_to_string ~info_rows state nodes in 1018 + let lines = String.split_on_char '\n' str in 1019 + let images = List.map (fun line -> Notty.I.string Notty.A.empty line) lines in 1020 + Notty.I.vcat images 1021 + ;;
+760
jj_tui/lib/render_jj_graph_tests.ml
··· 1 + open Render_jj_graph 2 + 3 + let%expect_test "recreate_target_graph" = 4 + (* Graph: a (working copy) has parents [c; d] where c->b->d 5 + First parent (c) gets node's column (0), second parent (d) branches to column 1. 6 + *) 7 + let d : node = 8 + { 9 + parents = [] 10 + ; creation_time = 4L 11 + ; working_copy = false 12 + ; immutable = false 13 + ; wip = false 14 + ; change_id = "" 15 + ; commit_id = "" 16 + ; description = "test commit" 17 + ; bookmarks = [] 18 + ; author_email = "test@example.com" 19 + ; author_timestamp = "2024-01-01T00:00:00Z" 20 + ; empty = false 21 + ; hidden = false 22 + ; divergent = false 23 + ; is_preview = false 24 + } 25 + in 26 + let b : node = 27 + { 28 + parents = [ d ] 29 + ; creation_time = 3L 30 + ; working_copy = false 31 + ; immutable = false 32 + ; wip = false 33 + ; change_id = "" 34 + ; commit_id = "" 35 + ; description = "test commit" 36 + ; bookmarks = [] 37 + ; author_email = "test@example.com" 38 + ; author_timestamp = "2024-01-01T00:00:00Z" 39 + ; empty = false 40 + ; hidden = false 41 + ; divergent = false 42 + ; is_preview = false 43 + } 44 + in 45 + let c : node = 46 + { 47 + parents = [ b ] 48 + ; creation_time = 2L 49 + ; working_copy = false 50 + ; immutable = false 51 + ; wip = false 52 + ; change_id = "" 53 + ; commit_id = "" 54 + ; description = "test commit" 55 + ; bookmarks = [] 56 + ; author_email = "test@example.com" 57 + ; author_timestamp = "2024-01-01T00:00:00Z" 58 + ; empty = false 59 + ; hidden = false 60 + ; divergent = false 61 + ; is_preview = false 62 + } 63 + in 64 + let a : node = 65 + { 66 + parents = [ c; d ] 67 + ; creation_time = 1L 68 + ; working_copy = true 69 + ; immutable = false 70 + ; wip = false 71 + ; change_id = "" 72 + ; commit_id = "" 73 + ; description = "test commit" 74 + ; bookmarks = [] 75 + ; author_email = "test@example.com" 76 + ; author_timestamp = "2024-01-01T00:00:00Z" 77 + ; empty = false 78 + ; hidden = false 79 + ; divergent = false 80 + ; is_preview = false 81 + } 82 + in 83 + let state : state = { depth = 0; columns = [||]; pending_joins = [] } in 84 + render_nodes_to_string state [ a; c; b; d ] |> print_endline; 85 + [%expect 86 + {| 87 + @ 88 + ├─╮ 89 + ○ │ 90 + ○ │ 91 + ├─╯ 92 + 93 + |}] 94 + ;; 95 + 96 + let%expect_test "complex_graph_golden_graph_only" = 97 + (* This test encodes the graph shape from the user-provided example. 98 + We only assert the graph glyphs (left side), not the metadata text. 99 + 100 + Topology (using the change ids from the example): 101 + - @ (wwtl...) has parents: yrsq..., otsz..., loxn... 102 + - ○ (xysm...) has parents: yrsq..., otsz..., loxn... 103 + - loxn..., otsz..., yrsq..., qlnop..., osynn... all have parent rzmu... 104 + - tzrqs... has parent osynn... 105 + - rzmu... has parent zzz... 106 + - zzz... is immutable (◆) 107 + *) 108 + let zzz : node = 109 + { 110 + parents = [] 111 + ; creation_time = 0L 112 + ; working_copy = false 113 + ; immutable = true 114 + ; wip = false 115 + ; change_id = "" 116 + ; commit_id = "" 117 + ; description = "test commit" 118 + ; bookmarks = [] 119 + ; author_email = "test@example.com" 120 + ; author_timestamp = "2024-01-01T00:00:00Z" 121 + ; empty = false 122 + ; hidden = false 123 + ; divergent = false 124 + ; is_preview = false 125 + } 126 + in 127 + let rzmu : node = 128 + { 129 + parents = [ zzz ] 130 + ; creation_time = 1L 131 + ; working_copy = false 132 + ; immutable = false 133 + ; wip = false 134 + ; change_id = "" 135 + ; commit_id = "" 136 + ; description = "test commit" 137 + ; bookmarks = [] 138 + ; author_email = "test@example.com" 139 + ; author_timestamp = "2024-01-01T00:00:00Z" 140 + ; empty = false 141 + ; hidden = false 142 + ; divergent = false 143 + ; is_preview = false 144 + } 145 + in 146 + let osynn : node = 147 + { 148 + parents = [ rzmu ] 149 + ; creation_time = 2L 150 + ; working_copy = false 151 + ; immutable = false 152 + ; wip = false 153 + ; change_id = "" 154 + ; commit_id = "" 155 + ; description = "test commit" 156 + ; bookmarks = [] 157 + ; author_email = "test@example.com" 158 + ; author_timestamp = "2024-01-01T00:00:00Z" 159 + ; empty = false 160 + ; hidden = false 161 + ; divergent = false 162 + ; is_preview = false 163 + } 164 + in 165 + let tzrqs : node = 166 + { 167 + parents = [ osynn ] 168 + ; creation_time = 3L 169 + ; working_copy = false 170 + ; immutable = false 171 + ; wip = false 172 + ; change_id = "" 173 + ; commit_id = "" 174 + ; description = "test commit" 175 + ; bookmarks = [] 176 + ; author_email = "test@example.com" 177 + ; author_timestamp = "2024-01-01T00:00:00Z" 178 + ; empty = false 179 + ; hidden = false 180 + ; divergent = false 181 + ; is_preview = false 182 + } 183 + in 184 + let qlnop : node = 185 + { 186 + parents = [ rzmu ] 187 + ; creation_time = 4L 188 + ; working_copy = false 189 + ; immutable = false 190 + ; wip = false 191 + ; change_id = "" 192 + ; commit_id = "" 193 + ; description = "test commit" 194 + ; bookmarks = [] 195 + ; author_email = "test@example.com" 196 + ; author_timestamp = "2024-01-01T00:00:00Z" 197 + ; empty = false 198 + ; hidden = false 199 + ; divergent = false 200 + ; is_preview = false 201 + } 202 + in 203 + let loxn : node = 204 + { 205 + parents = [ rzmu ] 206 + ; creation_time = 5L 207 + ; working_copy = false 208 + ; immutable = false 209 + ; wip = false 210 + ; change_id = "" 211 + ; commit_id = "" 212 + ; description = "test commit" 213 + ; bookmarks = [] 214 + ; author_email = "test@example.com" 215 + ; author_timestamp = "2024-01-01T00:00:00Z" 216 + ; empty = false 217 + ; hidden = false 218 + ; divergent = false 219 + ; is_preview = false 220 + } 221 + in 222 + let otsz : node = 223 + { 224 + parents = [ rzmu ] 225 + ; creation_time = 6L 226 + ; working_copy = false 227 + ; immutable = false 228 + ; wip = false 229 + ; change_id = "" 230 + ; commit_id = "" 231 + ; description = "test commit" 232 + ; bookmarks = [] 233 + ; author_email = "test@example.com" 234 + ; author_timestamp = "2024-01-01T00:00:00Z" 235 + ; empty = false 236 + ; hidden = false 237 + ; divergent = false 238 + ; is_preview = false 239 + } 240 + in 241 + let yrsq : node = 242 + { 243 + parents = [ rzmu ] 244 + ; creation_time = 7L 245 + ; working_copy = false 246 + ; immutable = false 247 + ; wip = false 248 + ; change_id = "" 249 + ; commit_id = "" 250 + ; description = "test commit" 251 + ; bookmarks = [] 252 + ; author_email = "test@example.com" 253 + ; author_timestamp = "2024-01-01T00:00:00Z" 254 + ; empty = false 255 + ; hidden = false 256 + ; divergent = false 257 + ; is_preview = false 258 + } 259 + in 260 + let xysm : node = 261 + { 262 + parents = [ yrsq; otsz; loxn ] 263 + ; creation_time = 8L 264 + ; working_copy = false 265 + ; immutable = false 266 + ; wip = false 267 + ; change_id = "" 268 + ; commit_id = "" 269 + ; description = "test commit" 270 + ; bookmarks = [] 271 + ; author_email = "test@example.com" 272 + ; author_timestamp = "2024-01-01T00:00:00Z" 273 + ; empty = false 274 + ; hidden = false 275 + ; divergent = false 276 + ; is_preview = false 277 + } 278 + in 279 + let wwtl : node = 280 + { 281 + parents = [ yrsq; otsz; loxn ] 282 + ; creation_time = 9L 283 + ; working_copy = true 284 + ; immutable = false 285 + ; wip = false 286 + ; change_id = "" 287 + ; commit_id = "" 288 + ; description = "test commit" 289 + ; bookmarks = [] 290 + ; author_email = "test@example.com" 291 + ; author_timestamp = "2024-01-01T00:00:00Z" 292 + ; empty = false 293 + ; hidden = false 294 + ; divergent = false 295 + ; is_preview = false 296 + } 297 + in 298 + (* Render order matching the example top-to-bottom. *) 299 + let state : state = { depth = 0; columns = [||]; pending_joins = [] } in 300 + let info_rows (n : node) = if n == loxn || n == tzrqs || n == rzmu then 1 else 0 in 301 + render_nodes_to_string 302 + ~info_rows 303 + state 304 + [ wwtl; xysm; loxn; otsz; yrsq; qlnop; tzrqs; osynn; rzmu; zzz ] 305 + |> print_endline; 306 + [%expect 307 + {| 308 + @ 309 + ├─┬─╮ 310 + │ │ │ ○ 311 + ╭─┬─┬─╯ 312 + │ │ ○ 313 + │ │ │ 314 + │ ○ │ 315 + │ ├─╯ 316 + ○ │ 317 + ├─╯ 318 + │ ○ 319 + ├─╯ 320 + │ ○ 321 + │ │ 322 + │ ○ 323 + ├─╯ 324 + 325 + 326 + 327 + |}] 328 + ;; 329 + 330 + type jj_output_author = { 331 + name : string 332 + ; email : string 333 + ; timestamp : string 334 + } 335 + [@@deriving yojson] 336 + 337 + type jj_output = { 338 + commit_id : string 339 + ; parents : string list 340 + ; change_id : string 341 + ; description : string 342 + ; working_copy : bool 343 + ; immutable : bool 344 + ; wip : bool 345 + ; author : jj_output_author 346 + ; committer : jj_output_author 347 + } 348 + [@@deriving yojson] 349 + 350 + let%expect_test "render_jj_output" = 351 + let read_file path = 352 + let ic = Stdlib.open_in_bin path in 353 + Fun.protect 354 + ~finally:(fun () -> Stdlib.close_in_noerr ic) 355 + (fun () -> 356 + let len = Stdlib.in_channel_length ic in 357 + Stdlib.really_input_string ic len) 358 + in 359 + let source_root = Sys.getenv_opt "DUNE_SOURCEROOT" |> Option.value ~default:"." in 360 + let file_input = read_file (Filename.concat source_root "test/jj_log.json") in 361 + let raw_nodes = 362 + file_input 363 + |> String.split_on_char '\n' 364 + |> List.filter (fun s -> String.length s > 0) 365 + |> List.map (fun x -> 366 + x |> Yojson.Safe.from_string |> jj_output_of_yojson |> Result.get_ok) 367 + in 368 + (* First pass: create all nodes without parents and populate hashtable *) 369 + let node_tbl : (string, node) Hashtbl.t = Hashtbl.create (List.length raw_nodes) in 370 + raw_nodes 371 + |> List.iter (fun jj_node -> 372 + let n : node = 373 + { 374 + parents = [] (* populated in second pass *) 375 + ; creation_time = Int64.of_int 0 376 + ; working_copy = jj_node.working_copy 377 + ; immutable = jj_node.immutable 378 + ; wip = jj_node.wip 379 + ; change_id = jj_node.change_id 380 + ; commit_id = jj_node.commit_id 381 + ; description = jj_node.description 382 + ; bookmarks = [] 383 + ; author_email = jj_node.author.email 384 + ; author_timestamp = jj_node.author.timestamp 385 + ; empty = false 386 + ; hidden = false 387 + ; divergent = false 388 + ; is_preview = false 389 + } 390 + in 391 + Hashtbl.add node_tbl jj_node.commit_id n); 392 + (* Second pass: link up parents in reverse order (so parents are resolved before children). 393 + We process in reverse so that when we look up a parent, it's already been updated with 394 + its own parents. Then we update the hashtable with the complete node. *) 395 + let rev_raw_nodes = List.rev raw_nodes in 396 + rev_raw_nodes 397 + |> List.iter (fun jj_node -> 398 + let parents = 399 + jj_node.parents 400 + |> List.map (fun parent_id -> 401 + match Hashtbl.find_opt node_tbl parent_id with 402 + | Some p -> 403 + p 404 + | None -> 405 + failwith 406 + (Printf.sprintf 407 + "Parent %s not found for node %s (change_id=%s)" 408 + parent_id 409 + jj_node.commit_id 410 + jj_node.change_id)) 411 + in 412 + (* Verify we didn't drop any parents *) 413 + if List.length parents <> List.length jj_node.parents 414 + then 415 + failwith 416 + (Printf.sprintf 417 + "Parent count mismatch for node %s: expected %d, got %d" 418 + jj_node.commit_id 419 + (List.length jj_node.parents) 420 + (List.length parents)); 421 + let node = Hashtbl.find node_tbl jj_node.commit_id in 422 + let updated_node = { node with parents } in 423 + Hashtbl.replace node_tbl jj_node.commit_id updated_node); 424 + (* Extract nodes in original order, now with proper parent links *) 425 + let processed_nodes = 426 + raw_nodes |> List.map (fun jj_node -> Hashtbl.find node_tbl jj_node.commit_id) 427 + in 428 + let state : state = { depth = 0; columns = [||]; pending_joins = [] } in 429 + render_nodes_to_string state processed_nodes |> print_endline; 430 + [%expect 431 + {| 432 + 433 + │ ○ 434 + ╭─┼─┬─╮ 435 + │ │ │ │ ○ 436 + │ │ ╭───┤ 437 + │ │ │ │ ○ 438 + │ │ │ │ ○ 439 + │ │ │ │ ○ 440 + │ ├─────╯ 441 + │ ○ │ │ 442 + ╭─┴─╮ │ 443 + │ ○ │ 444 + │ ├─╯ 445 + @ │ 446 + ├───╯ 447 + 448 + ├─╮ 449 + │ │ ○ 450 + │ │ ○ 451 + ╭─┬─╯ 452 + │ ○ 453 + │ ◌ 454 + │ ○ 455 + │ ├─┬─╮ 456 + │ │ │ ○ 457 + ├─────╯ 458 + │ │ ○ 459 + ├───╯ 460 + │ ○ 461 + ├─╯ 462 + │ ○ 463 + ├─╯ 464 + │ ○ 465 + ├─╯ 466 + 467 + 468 + |}] 469 + ;; 470 + 471 + let%expect_test "make_elided_node" = 472 + let elided = Render_jj_graph.make_elided_node () in 473 + Printf.printf "commit_id: %s\n" elided.commit_id; 474 + Printf.printf "change_id: %s\n" elided.change_id; 475 + Printf.printf "description: %s\n" elided.description; 476 + Printf.printf "hidden: %b\n" elided.hidden; 477 + [%expect 478 + {| 479 + commit_id: ~ELIDED~ 480 + change_id: ~ELIDED~ 481 + description: (elided revisions) 482 + hidden: true 483 + |}] 484 + ;; 485 + 486 + let%expect_test "is_elided_true" = 487 + let elided = Render_jj_graph.make_elided_node () in 488 + Printf.printf "is_elided: %b\n" (Render_jj_graph.is_elided elided); 489 + [%expect 490 + {| 491 + is_elided: true 492 + |}] 493 + ;; 494 + 495 + let%expect_test "is_elided_false" = 496 + let normal = 497 + { 498 + parents = [] 499 + ; creation_time = Int64.zero 500 + ; working_copy = false 501 + ; immutable = false 502 + ; wip = false 503 + ; change_id = "abc" 504 + ; commit_id = "123" 505 + ; description = "test" 506 + ; bookmarks = [] 507 + ; author_email = "test@example.com" 508 + ; author_timestamp = "2024-01-01" 509 + ; empty = false 510 + ; hidden = false 511 + ; divergent = false 512 + ; is_preview = false 513 + } 514 + in 515 + Printf.printf "is_elided: %b\n" (Render_jj_graph.is_elided normal); 516 + [%expect 517 + {| 518 + is_elided: false 519 + |}] 520 + ;; 521 + 522 + let%expect_test "render_nodes_structured_simple" = 523 + let parent : node = 524 + { 525 + parents = [] 526 + ; creation_time = Int64.zero 527 + ; working_copy = false 528 + ; immutable = false 529 + ; wip = false 530 + ; change_id = "p" 531 + ; commit_id = "parent" 532 + ; description = "Parent" 533 + ; bookmarks = [] 534 + ; author_email = "test@example.com" 535 + ; author_timestamp = "2024-01-01" 536 + ; empty = false 537 + ; hidden = false 538 + ; divergent = false 539 + ; is_preview = false 540 + } 541 + in 542 + let child : node = 543 + { 544 + parent with 545 + parents = [ parent ] 546 + ; commit_id = "child" 547 + ; change_id = "c" 548 + ; description = "Child" 549 + } 550 + in 551 + let state : state = { depth = 0; columns = [||]; pending_joins = [] } in 552 + let rows = 553 + Render_jj_graph.render_nodes_structured state [ child; parent ] ~info_lines:(fun _ -> 554 + 1) 555 + in 556 + Printf.printf "Total rows: %d\n" (List.length rows); 557 + List.iteri 558 + (fun i row -> 559 + let row_type_str = 560 + match row.row_type with 561 + | NodeRow -> 562 + "NodeRow" 563 + | LinkRow -> 564 + "LinkRow" 565 + | PadRow -> 566 + "PadRow" 567 + | TermRow -> 568 + "TermRow" 569 + in 570 + Printf.printf 571 + "Row %d: %s | node=%s | graph='%s'\n" 572 + i 573 + row_type_str 574 + row.node.commit_id 575 + row.graph_chars) 576 + rows; 577 + [%expect 578 + {| 579 + Total rows: 4 580 + Row 0: NodeRow | node=child | graph='○' 581 + Row 1: PadRow | node=child | graph='│' 582 + Row 2: NodeRow | node=parent | graph='○' 583 + Row 3: PadRow | node=parent | graph='' 584 + |}] 585 + ;; 586 + 587 + let%expect_test "render_nodes_structured_row_types" = 588 + let d : node = 589 + { 590 + parents = [] 591 + ; creation_time = 4L 592 + ; working_copy = false 593 + ; immutable = false 594 + ; wip = false 595 + ; change_id = "d" 596 + ; commit_id = "d" 597 + ; description = "test commit" 598 + ; bookmarks = [] 599 + ; author_email = "test@example.com" 600 + ; author_timestamp = "2024-01-01T00:00:00Z" 601 + ; empty = false 602 + ; hidden = false 603 + ; divergent = false 604 + ; is_preview = false 605 + } 606 + in 607 + let b : node = 608 + { 609 + parents = [ d ] 610 + ; creation_time = 3L 611 + ; working_copy = false 612 + ; immutable = false 613 + ; wip = false 614 + ; change_id = "b" 615 + ; commit_id = "b" 616 + ; description = "test commit" 617 + ; bookmarks = [] 618 + ; author_email = "test@example.com" 619 + ; author_timestamp = "2024-01-01T00:00:00Z" 620 + ; empty = false 621 + ; hidden = false 622 + ; divergent = false 623 + ; is_preview = false 624 + } 625 + in 626 + let c : node = 627 + { 628 + parents = [ b ] 629 + ; creation_time = 2L 630 + ; working_copy = false 631 + ; immutable = false 632 + ; wip = false 633 + ; change_id = "c" 634 + ; commit_id = "c" 635 + ; description = "test commit" 636 + ; bookmarks = [] 637 + ; author_email = "test@example.com" 638 + ; author_timestamp = "2024-01-01T00:00:00Z" 639 + ; empty = false 640 + ; hidden = false 641 + ; divergent = false 642 + ; is_preview = false 643 + } 644 + in 645 + let a : node = 646 + { 647 + parents = [ c; d ] 648 + ; creation_time = 1L 649 + ; working_copy = true 650 + ; immutable = false 651 + ; wip = false 652 + ; change_id = "a" 653 + ; commit_id = "a" 654 + ; description = "test commit" 655 + ; bookmarks = [] 656 + ; author_email = "test@example.com" 657 + ; author_timestamp = "2024-01-01T00:00:00Z" 658 + ; empty = false 659 + ; hidden = false 660 + ; divergent = false 661 + ; is_preview = false 662 + } 663 + in 664 + let state : state = { depth = 0; columns = [||]; pending_joins = [] } in 665 + let rows = 666 + Render_jj_graph.render_nodes_structured state [ a; c; b; d ] ~info_lines:(fun _ -> 0) 667 + in 668 + Printf.printf "Total rows: %d\n" (List.length rows); 669 + let node_rows = List.filter (fun r -> r.row_type = NodeRow) rows in 670 + let link_rows = List.filter (fun r -> r.row_type = LinkRow) rows in 671 + let pad_rows = List.filter (fun r -> r.row_type = PadRow) rows in 672 + Printf.printf 673 + "NodeRows: %d, LinkRows: %d, PadRows: %d\n" 674 + (List.length node_rows) 675 + (List.length link_rows) 676 + (List.length pad_rows); 677 + List.iter 678 + (fun row -> 679 + let row_type_str = 680 + match row.row_type with 681 + | NodeRow -> 682 + "NodeRow" 683 + | LinkRow -> 684 + "LinkRow" 685 + | PadRow -> 686 + "PadRow" 687 + | TermRow -> 688 + "TermRow" 689 + in 690 + Printf.printf "%s: '%s'\n" row_type_str row.graph_chars) 691 + rows; 692 + [%expect 693 + {| 694 + Total rows: 6 695 + NodeRows: 4, LinkRows: 2, PadRows: 0 696 + NodeRow: '@' 697 + LinkRow: '├─╮' 698 + NodeRow: '○ │' 699 + NodeRow: '○ │' 700 + LinkRow: '├─╯' 701 + NodeRow: '○' 702 + |}] 703 + ;; 704 + 705 + let%expect_test "elided_parent_creates_termination_line" = 706 + let elided = Render_jj_graph.make_elided_node () in 707 + let child : node = 708 + { 709 + parents = [ elided ] 710 + ; creation_time = Int64.zero 711 + ; working_copy = false 712 + ; immutable = false 713 + ; wip = false 714 + ; change_id = "c" 715 + ; commit_id = "child" 716 + ; description = "Child with elided parent" 717 + ; bookmarks = [] 718 + ; author_email = "test@example.com" 719 + ; author_timestamp = "2024-01-01" 720 + ; empty = false 721 + ; hidden = false 722 + ; divergent = false 723 + ; is_preview = false 724 + } 725 + in 726 + let state : state = { depth = 0; columns = [||]; pending_joins = [] } in 727 + let rows = 728 + Render_jj_graph.render_nodes_structured state [ child ] ~info_lines:(fun _ -> 1) 729 + in 730 + Printf.printf "Total rows: %d\n" (List.length rows); 731 + List.iteri 732 + (fun i row -> 733 + let row_type_str = 734 + match row.row_type with 735 + | NodeRow -> 736 + "NodeRow" 737 + | LinkRow -> 738 + "LinkRow" 739 + | PadRow -> 740 + "PadRow" 741 + | TermRow -> 742 + "TermRow" 743 + in 744 + Printf.printf 745 + "Row %d: %s | node=%s | graph='%s'\n" 746 + i 747 + row_type_str 748 + row.node.commit_id 749 + row.graph_chars) 750 + rows; 751 + [%expect 752 + {| 753 + Total rows: 5 754 + Row 0: NodeRow | node=child | graph='○' 755 + Row 1: PadRow | node=child | graph='│' 756 + Row 2: TermRow | node=child | graph='~' 757 + Row 3: PadRow | node=child | graph='' 758 + Row 4: PadRow | node=child | graph='' 759 + |}] 760 + ;;
+22 -23
jj_tui/lib/test/jj_log.json
··· 1 - 2 - {"commit_id":"eb18e72467676c4561bd84dd7b3818f56798f615","parents":["0c7424083cb13984968e9a2445ebcda7fc529367"],"change_id":"oyksztqkrlptsrtqypkuwywprkommwyw","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 3 - {"commit_id":"d790353859e26e1eac6e2cd609a3314e1e0eed00","parents":["1ece15f3968dacd34d6b82f9f75bf8ad48517727","c117fb6e86e4d289d192fd2671780ea29430996b"],"change_id":"ryytrytktumkkzknwwutqztmvuxlzzzt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 4 - {"commit_id":"c117fb6e86e4d289d192fd2671780ea29430996b","parents":["9f7d1f591375e75373d51030ba5fff1a8ab0bbaf"],"change_id":"qxpnouvlkkknpwupwlottoutzortmmzz","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 5 - {"commit_id":"9f7d1f591375e75373d51030ba5fff1a8ab0bbaf","parents":["88fe78b7b02bdda96e091b0a5eb4269a4db4b5cb"],"change_id":"lrrvvvpkpwznkwulrmynxqpmzmzpuwwt","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:17:28+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 6 - {"commit_id":"88fe78b7b02bdda96e091b0a5eb4269a4db4b5cb","parents":["d71b56f2092397adc186797175e1799e8262d12f"],"change_id":"pqplusztrmmklmosskmxlkkpksqolmtl","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:10+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 7 - {"commit_id":"6bd8e68976a7701a1231f336f74c5edc9c2cbaed","parents":["d71b56f2092397adc186797175e1799e8262d12f","0c7424083cb13984968e9a2445ebcda7fc529367","1ece15f3968dacd34d6b82f9f75bf8ad48517727","2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"owrxkmrkzqmsrkmpnotoptsxmsupnzyq","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:14+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 8 - {"commit_id":"d71b56f2092397adc186797175e1799e8262d12f","parents":["0c7424083cb13984968e9a2445ebcda7fc529367","1ece15f3968dacd34d6b82f9f75bf8ad48517727"],"change_id":"sqvmzllpqtqvvswlkkpvxuquputsowwt","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 9 - {"commit_id":"0c7424083cb13984968e9a2445ebcda7fc529367","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"nqvkmksporsourrpzqwtvrpvllnnpvus","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:55+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-05T19:42:18+08:00"}} 10 - {"commit_id":"2271919927f7b342b6ef51fe8732dd0653aa3496","parents":["ec28d2ea113664b05a196ceea9779fc485a676d2"],"change_id":"uunkomlmyuszvyxvrrnoqpwwqynomlpq","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"}} 11 - {"commit_id":"1ece15f3968dacd34d6b82f9f75bf8ad48517727","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"wyxswxtpvsypppknvrzmltvnypsyuqtm","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:36+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 12 - {"commit_id":"2654e022a51769299a0351f034073f1ef2252c28","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"lqvvmmrvqlwxwvxmpnunslvrnrutluoz","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:57:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 13 - {"commit_id":"ec28d2ea113664b05a196ceea9779fc485a676d2","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"pxnknktulrwtrqptkktsopqzpwxunnym","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 14 - {"commit_id":"67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf","parents":["3386e4f5827ccb7f05473f09217b9d9107bf4192"],"change_id":"urrykmzkmrtulnzsousvkuytxotplyku","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 15 - {"commit_id":"3386e4f5827ccb7f05473f09217b9d9107bf4192","parents":["d9b6e8ee3c3082659c392b819ef34607c5306dfc"],"change_id":"qwpmxoltwxskqtpzxuxtkuyrstuymvlt","description":"wip:5\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:50+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 16 - {"commit_id":"d9b6e8ee3c3082659c392b819ef34607c5306dfc","parents":["a8f75d20713496cacfffec5a3669976a14ff7f86","74a5db23865de05e3b05ebed4904413fe9791bcb","832b8aafa20c8fc6b3663da9a72f2623551a3087"],"change_id":"pymnwxmzqlxpmszpsupxlxosnvtwxsrx","description":"3\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:38+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 17 - {"commit_id":"a8f75d20713496cacfffec5a3669976a14ff7f86","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"punyqstqnuttlxnoyyyxylqwqxwpxtln","description":"66\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:56:33+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"}} 18 - {"commit_id":"74a5db23865de05e3b05ebed4904413fe9791bcb","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"xxyqrwlsvqtsmxrsyrxymonrlltskzrt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:43+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:59:32+08:00"}} 19 - {"commit_id":"8e6225509a1144b6ddae51163a6878e2df96be3f","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"uslsksyxmwsspownwxsqkoqxvwzyvwss","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"}} 20 - {"commit_id":"c5f072c36ee17ff1f35a7b171d5d8fd536e7eb42","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"toyqvunsxtyyxrzpryuwxnwtvttoqqwv","description":"2\\\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"}} 21 - {"commit_id":"832b8aafa20c8fc6b3663da9a72f2623551a3087","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"nulwxyuovkzqopkyslmvumtlvmqozvtt","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"}} 22 - {"commit_id":"af63945d4563be2dc9d0b37592417f38ae5b6a8e","parents":["0000000000000000000000000000000000000000"],"change_id":"ykkkpqptrszzqyzmrlqkzszxlkuuyqsk","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"}} 23 - {"commit_id":"0000000000000000000000000000000000000000","parents":[],"change_id":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","description":"","author":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"committer":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"}} 1 + {"commit_id":"aa63cb9874a0e27b62f9ff82b9d89bd475c6c688","parents":["e63ecaf3368a1d3e546cad035ac7ff167822b2ae"],"change_id":"oyksztqkrlptsrtqypkuwywprkommwyw","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 2 + {"commit_id":"4fd6dbf11017c9a41b9f14b002c33968675b44cb","parents":["0845892c5835e6dce063f6565bf6b9d210443612","e63ecaf3368a1d3e546cad035ac7ff167822b2ae","1ece15f3968dacd34d6b82f9f75bf8ad48517727","2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"owrxkmrkzqmsrkmpnotoptsxmsupnzyq","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:14+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 3 + {"commit_id":"f70e9bfa2a36c7b3e09dd6d419e234157e33e407","parents":["1ece15f3968dacd34d6b82f9f75bf8ad48517727","1a0b50c7d6c266036b4c5519a6d711a3efa27183"],"change_id":"ryytrytktumkkzknwwutqztmvuxlzzzt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 4 + {"commit_id":"1a0b50c7d6c266036b4c5519a6d711a3efa27183","parents":["b7d4baf6458ade7cd3f183aa5fd102350e9a70d1"],"change_id":"qxpnouvlkkknpwupwlottoutzortmmzz","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 5 + {"commit_id":"b7d4baf6458ade7cd3f183aa5fd102350e9a70d1","parents":["66f6f0f2145d92348946cfb8926fd0c654b34932"],"change_id":"lrrvvvpkpwznkwulrmynxqpmzmzpuwwt","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:17:28+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 6 + {"commit_id":"66f6f0f2145d92348946cfb8926fd0c654b34932","parents":["0845892c5835e6dce063f6565bf6b9d210443612"],"change_id":"pqplusztrmmklmosskmxlkkpksqolmtl","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:10+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 7 + {"commit_id":"0845892c5835e6dce063f6565bf6b9d210443612","parents":["e63ecaf3368a1d3e546cad035ac7ff167822b2ae","1ece15f3968dacd34d6b82f9f75bf8ad48517727"],"change_id":"sqvmzllpqtqvvswlkkpvxuquputsowwt","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 8 + {"commit_id":"1ece15f3968dacd34d6b82f9f75bf8ad48517727","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"wyxswxtpvsypppknvrzmltvnypsyuqtm","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:36+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 9 + {"commit_id":"e63ecaf3368a1d3e546cad035ac7ff167822b2ae","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"nqvkmksporsourrpzqwtvrpvllnnpvus","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:55+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":true,"immutable":false,"wip":false} 10 + {"commit_id":"2654e022a51769299a0351f034073f1ef2252c28","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"lqvvmmrvqlwxwvxmpnunslvrnrutluoz","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:57:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 11 + {"commit_id":"2271919927f7b342b6ef51fe8732dd0653aa3496","parents":["ec28d2ea113664b05a196ceea9779fc485a676d2"],"change_id":"uunkomlmyuszvyxvrrnoqpwwqynomlpq","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"working_copy":false,"immutable":false,"wip":false} 12 + {"commit_id":"ec28d2ea113664b05a196ceea9779fc485a676d2","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"pxnknktulrwtrqptkktsopqzpwxunnym","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 13 + {"commit_id":"67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf","parents":["3386e4f5827ccb7f05473f09217b9d9107bf4192"],"change_id":"urrykmzkmrtulnzsousvkuytxotplyku","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 14 + {"commit_id":"3386e4f5827ccb7f05473f09217b9d9107bf4192","parents":["d9b6e8ee3c3082659c392b819ef34607c5306dfc"],"change_id":"qwpmxoltwxskqtpzxuxtkuyrstuymvlt","description":"wip:5\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:50+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":true} 15 + {"commit_id":"d9b6e8ee3c3082659c392b819ef34607c5306dfc","parents":["a8f75d20713496cacfffec5a3669976a14ff7f86","74a5db23865de05e3b05ebed4904413fe9791bcb","832b8aafa20c8fc6b3663da9a72f2623551a3087"],"change_id":"pymnwxmzqlxpmszpsupxlxosnvtwxsrx","description":"3\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:38+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 16 + {"commit_id":"832b8aafa20c8fc6b3663da9a72f2623551a3087","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"nulwxyuovkzqopkyslmvumtlvmqozvtt","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"working_copy":false,"immutable":false,"wip":false} 17 + {"commit_id":"74a5db23865de05e3b05ebed4904413fe9791bcb","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"xxyqrwlsvqtsmxrsyrxymonrlltskzrt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:43+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:59:32+08:00"},"working_copy":false,"immutable":false,"wip":false} 18 + {"commit_id":"a8f75d20713496cacfffec5a3669976a14ff7f86","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"punyqstqnuttlxnoyyyxylqwqxwpxtln","description":"66\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:56:33+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 19 + {"commit_id":"8e6225509a1144b6ddae51163a6878e2df96be3f","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"uslsksyxmwsspownwxsqkoqxvwzyvwss","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"working_copy":false,"immutable":false,"wip":false} 20 + {"commit_id":"c5f072c36ee17ff1f35a7b171d5d8fd536e7eb42","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"toyqvunsxtyyxrzpryuwxnwtvttoqqwv","description":"2\\\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"working_copy":false,"immutable":false,"wip":false} 21 + {"commit_id":"af63945d4563be2dc9d0b37592417f38ae5b6a8e","parents":["0000000000000000000000000000000000000000"],"change_id":"ykkkpqptrszzqyzmrlqkzszxlkuuyqsk","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"working_copy":false,"immutable":false,"wip":false} 22 + {"commit_id":"0000000000000000000000000000000000000000","parents":[],"change_id":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","description":"","author":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"committer":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"working_copy":false,"immutable":true,"wip":false}
+2 -5
scripts/update_render_jj_graph_test_data.sh
··· 14 14 # - Output is JSONL (one JSON object per line). 15 15 # - `wip` is a heuristic derived from the description's first line starting with "wip:". 16 16 17 - REVSET="${1:-all()}" 17 + REVSET="" 18 18 OUTFILE="${2:-test/jj_log.json}" 19 19 20 20 echo "Updating $OUTFILE with revset: $REVSET" >&2 ··· 41 41 JJTEMPLATE 42 42 ) 43 43 44 - if [[ "${VERBOSE:-0}" == "1" ]]; then 45 - echo "TEMPLATE: $TEMPLATE" >&2 46 - fi 47 44 48 45 mkdir -p "$(dirname "$OUTFILE")" 49 46 # We want a stable "top-to-bottom" order like `jj log`, but without graph text. 50 47 # Write to a temp file first so parse errors don't clobber the existing fixture. 51 48 tmp_out="${OUTFILE}.tmp" 52 - jj log --no-graph -r "$REVSET" -T "$TEMPLATE" > "$tmp_out" 49 + jj log -T "$TEMPLATE" > "$tmp_out" 53 50 mv "$tmp_out" "$OUTFILE" 54 51 55 52 echo "Wrote $OUTFILE" >&2
+22 -22
test/jj_log.json
··· 1 - {"commit_id":"ad5c56c36937bbe32cf28c84995b1ecdb128a316","parents":["e0b6949987fac4e0eea6a3a25c0a6baa3af5a422"],"change_id":"oyksztqkrlptsrtqypkuwywprkommwyw","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 2 - {"commit_id":"56bfa4ebde87b60031cbf3d6f4daf18e910b743a","parents":["1ece15f3968dacd34d6b82f9f75bf8ad48517727","288da14d60989db2b114f661f724197184339a80"],"change_id":"ryytrytktumkkzknwwutqztmvuxlzzzt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 3 - {"commit_id":"288da14d60989db2b114f661f724197184339a80","parents":["3467204493ea4501b87ddcdc89811b817588f308"],"change_id":"qxpnouvlkkknpwupwlottoutzortmmzz","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 4 - {"commit_id":"3467204493ea4501b87ddcdc89811b817588f308","parents":["d4deb604d80e25f7eca71d17c23dc7c1c408d9e1"],"change_id":"lrrvvvpkpwznkwulrmynxqpmzmzpuwwt","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:17:28+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 5 - {"commit_id":"d4deb604d80e25f7eca71d17c23dc7c1c408d9e1","parents":["36a62c631949cdc12a148241f2e73a23effa7c09"],"change_id":"pqplusztrmmklmosskmxlkkpksqolmtl","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:10+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 6 - {"commit_id":"586553f52a40f54482e62b7fd457f80132da2b5c","parents":["36a62c631949cdc12a148241f2e73a23effa7c09","e0b6949987fac4e0eea6a3a25c0a6baa3af5a422","1ece15f3968dacd34d6b82f9f75bf8ad48517727","2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"owrxkmrkzqmsrkmpnotoptsxmsupnzyq","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:14+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 7 - {"commit_id":"36a62c631949cdc12a148241f2e73a23effa7c09","parents":["e0b6949987fac4e0eea6a3a25c0a6baa3af5a422","1ece15f3968dacd34d6b82f9f75bf8ad48517727"],"change_id":"sqvmzllpqtqvvswlkkpvxuquputsowwt","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":false,"immutable":false,"wip":false} 8 - {"commit_id":"e0b6949987fac4e0eea6a3a25c0a6baa3af5a422","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"nqvkmksporsourrpzqwtvrpvllnnpvus","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:55+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-06T14:33:24+08:00"},"working_copy":true,"immutable":false,"wip":false} 9 - {"commit_id":"2271919927f7b342b6ef51fe8732dd0653aa3496","parents":["ec28d2ea113664b05a196ceea9779fc485a676d2"],"change_id":"uunkomlmyuszvyxvrrnoqpwwqynomlpq","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"working_copy":false,"immutable":false,"wip":false} 10 - {"commit_id":"1ece15f3968dacd34d6b82f9f75bf8ad48517727","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"wyxswxtpvsypppknvrzmltvnypsyuqtm","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:36+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 11 - {"commit_id":"2654e022a51769299a0351f034073f1ef2252c28","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"lqvvmmrvqlwxwvxmpnunslvrnrutluoz","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:57:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 12 - {"commit_id":"ec28d2ea113664b05a196ceea9779fc485a676d2","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"pxnknktulrwtrqptkktsopqzpwxunnym","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 13 - {"commit_id":"67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf","parents":["3386e4f5827ccb7f05473f09217b9d9107bf4192"],"change_id":"urrykmzkmrtulnzsousvkuytxotplyku","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 14 - {"commit_id":"3386e4f5827ccb7f05473f09217b9d9107bf4192","parents":["d9b6e8ee3c3082659c392b819ef34607c5306dfc"],"change_id":"qwpmxoltwxskqtpzxuxtkuyrstuymvlt","description":"wip:5\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:50+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":true} 15 - {"commit_id":"d9b6e8ee3c3082659c392b819ef34607c5306dfc","parents":["a8f75d20713496cacfffec5a3669976a14ff7f86","74a5db23865de05e3b05ebed4904413fe9791bcb","832b8aafa20c8fc6b3663da9a72f2623551a3087"],"change_id":"pymnwxmzqlxpmszpsupxlxosnvtwxsrx","description":"3\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:38+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 16 - {"commit_id":"a8f75d20713496cacfffec5a3669976a14ff7f86","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"punyqstqnuttlxnoyyyxylqwqxwpxtln","description":"66\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:56:33+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 17 - {"commit_id":"74a5db23865de05e3b05ebed4904413fe9791bcb","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"xxyqrwlsvqtsmxrsyrxymonrlltskzrt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:43+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:59:32+08:00"},"working_copy":false,"immutable":false,"wip":false} 18 - {"commit_id":"8e6225509a1144b6ddae51163a6878e2df96be3f","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"uslsksyxmwsspownwxsqkoqxvwzyvwss","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"working_copy":false,"immutable":false,"wip":false} 19 - {"commit_id":"c5f072c36ee17ff1f35a7b171d5d8fd536e7eb42","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"toyqvunsxtyyxrzpryuwxnwtvttoqqwv","description":"2\\\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"working_copy":false,"immutable":false,"wip":false} 20 - {"commit_id":"832b8aafa20c8fc6b3663da9a72f2623551a3087","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"nulwxyuovkzqopkyslmvumtlvmqozvtt","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"working_copy":false,"immutable":false,"wip":false} 21 - {"commit_id":"af63945d4563be2dc9d0b37592417f38ae5b6a8e","parents":["0000000000000000000000000000000000000000"],"change_id":"ykkkpqptrszzqyzmrlqkzszxlkuuyqsk","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"working_copy":false,"immutable":false,"wip":false} 22 - {"commit_id":"0000000000000000000000000000000000000000","parents":[],"change_id":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","description":"","author":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"committer":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"working_copy":false,"immutable":true,"wip":false} 1 + {"commit_id":"aa63cb9874a0e27b62f9ff82b9d89bd475c6c688","parents":["e63ecaf3368a1d3e546cad035ac7ff167822b2ae"],"change_id":"oyksztqkrlptsrtqypkuwywprkommwyw","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 2 + {"commit_id":"4fd6dbf11017c9a41b9f14b002c33968675b44cb","parents":["0845892c5835e6dce063f6565bf6b9d210443612","e63ecaf3368a1d3e546cad035ac7ff167822b2ae","1ece15f3968dacd34d6b82f9f75bf8ad48517727","2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"owrxkmrkzqmsrkmpnotoptsxmsupnzyq","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:14+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 3 + {"commit_id":"f70e9bfa2a36c7b3e09dd6d419e234157e33e407","parents":["1ece15f3968dacd34d6b82f9f75bf8ad48517727","1a0b50c7d6c266036b4c5519a6d711a3efa27183"],"change_id":"ryytrytktumkkzknwwutqztmvuxlzzzt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 4 + {"commit_id":"1a0b50c7d6c266036b4c5519a6d711a3efa27183","parents":["b7d4baf6458ade7cd3f183aa5fd102350e9a70d1"],"change_id":"qxpnouvlkkknpwupwlottoutzortmmzz","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 5 + {"commit_id":"b7d4baf6458ade7cd3f183aa5fd102350e9a70d1","parents":["66f6f0f2145d92348946cfb8926fd0c654b34932"],"change_id":"lrrvvvpkpwznkwulrmynxqpmzmzpuwwt","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:17:28+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 6 + {"commit_id":"66f6f0f2145d92348946cfb8926fd0c654b34932","parents":["0845892c5835e6dce063f6565bf6b9d210443612"],"change_id":"pqplusztrmmklmosskmxlkkpksqolmtl","description":"222\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:18:10+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 7 + {"commit_id":"0845892c5835e6dce063f6565bf6b9d210443612","parents":["e63ecaf3368a1d3e546cad035ac7ff167822b2ae","1ece15f3968dacd34d6b82f9f75bf8ad48517727"],"change_id":"sqvmzllpqtqvvswlkkpvxuquputsowwt","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:27:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":false,"immutable":false,"wip":false} 8 + {"commit_id":"1ece15f3968dacd34d6b82f9f75bf8ad48517727","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"wyxswxtpvsypppknvrzmltvnypsyuqtm","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:36+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 9 + {"commit_id":"e63ecaf3368a1d3e546cad035ac7ff167822b2ae","parents":["2654e022a51769299a0351f034073f1ef2252c28"],"change_id":"nqvkmksporsourrpzqwtvrpvllnnpvus","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T18:25:55+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-15T01:33:07+08:00"},"working_copy":true,"immutable":false,"wip":false} 10 + {"commit_id":"2654e022a51769299a0351f034073f1ef2252c28","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"lqvvmmrvqlwxwvxmpnunslvrnrutluoz","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:57:41+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 11 + {"commit_id":"2271919927f7b342b6ef51fe8732dd0653aa3496","parents":["ec28d2ea113664b05a196ceea9779fc485a676d2"],"change_id":"uunkomlmyuszvyxvrrnoqpwwqynomlpq","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:16:53+08:00"},"working_copy":false,"immutable":false,"wip":false} 12 + {"commit_id":"ec28d2ea113664b05a196ceea9779fc485a676d2","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e","67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf"],"change_id":"pxnknktulrwtrqptkktsopqzpwxunnym","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:25+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 13 + {"commit_id":"67a7da0d9c3f6d995e60a867b0c33af5a5cf99cf","parents":["3386e4f5827ccb7f05473f09217b9d9107bf4192"],"change_id":"urrykmzkmrtulnzsousvkuytxotplyku","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:51:16+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 14 + {"commit_id":"3386e4f5827ccb7f05473f09217b9d9107bf4192","parents":["d9b6e8ee3c3082659c392b819ef34607c5306dfc"],"change_id":"qwpmxoltwxskqtpzxuxtkuyrstuymvlt","description":"wip:5\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:50+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":true} 15 + {"commit_id":"d9b6e8ee3c3082659c392b819ef34607c5306dfc","parents":["a8f75d20713496cacfffec5a3669976a14ff7f86","74a5db23865de05e3b05ebed4904413fe9791bcb","832b8aafa20c8fc6b3663da9a72f2623551a3087"],"change_id":"pymnwxmzqlxpmszpsupxlxosnvtwxsrx","description":"3\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:38+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 16 + {"commit_id":"832b8aafa20c8fc6b3663da9a72f2623551a3087","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"nulwxyuovkzqopkyslmvumtlvmqozvtt","description":"2\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:11:00+08:00"},"working_copy":false,"immutable":false,"wip":false} 17 + {"commit_id":"74a5db23865de05e3b05ebed4904413fe9791bcb","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"xxyqrwlsvqtsmxrsyrxymonrlltskzrt","description":"22\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:12:43+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:59:32+08:00"},"working_copy":false,"immutable":false,"wip":false} 18 + {"commit_id":"a8f75d20713496cacfffec5a3669976a14ff7f86","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"punyqstqnuttlxnoyyyxylqwqxwpxtln","description":"66\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:56:33+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T09:13:06+08:00"},"working_copy":false,"immutable":false,"wip":false} 19 + {"commit_id":"8e6225509a1144b6ddae51163a6878e2df96be3f","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"uslsksyxmwsspownwxsqkoqxvwzyvwss","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2026-01-02T08:57:51+08:00"},"working_copy":false,"immutable":false,"wip":false} 20 + {"commit_id":"c5f072c36ee17ff1f35a7b171d5d8fd536e7eb42","parents":["af63945d4563be2dc9d0b37592417f38ae5b6a8e"],"change_id":"toyqvunsxtyyxrzpryuwxnwtvttoqqwv","description":"2\\\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T17:53:22+08:00"},"working_copy":false,"immutable":false,"wip":false} 21 + {"commit_id":"af63945d4563be2dc9d0b37592417f38ae5b6a8e","parents":["0000000000000000000000000000000000000000"],"change_id":"ykkkpqptrszzqyzmrlqkzszxlkuuyqsk","description":"1\n","author":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"committer":{"name":"Eli Dowling","email":"eli.jambu@gmail.com","timestamp":"2025-12-23T16:10:54+08:00"},"working_copy":false,"immutable":false,"wip":false} 22 + {"commit_id":"0000000000000000000000000000000000000000","parents":[],"change_id":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","description":"","author":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"committer":{"name":"","email":"","timestamp":"1970-01-01T00:00:00Z"},"working_copy":false,"immutable":true,"wip":false}