···11+# Learnings - Task 2.1: Extend node type
22+33+## Task Completed: 2026-01-15
44+55+### What Was Done
66+Extended the `node` type in `render_jj_graph.ml` with 8 new fields to support richer display information and preview functionality.
77+88+### Files Modified
99+1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph.ml` - Extended node type definition
1010+2. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph_tests.ml` - Updated 14 node creation sites in tests
1111+3. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/jj_json.ml` - Updated commits_to_nodes to populate new fields
1212+1313+### New Fields Added
1414+```ocaml
1515+; description : string
1616+; bookmarks : string list
1717+; author_email : string
1818+; author_timestamp : string
1919+; empty : bool
2020+; hidden : bool
2121+; divergent : bool
2222+; is_preview : bool
2323+```
2424+2525+### Key Patterns Observed
2626+2727+#### 1. Type Extension Strategy
2828+- Extended the record type first
2929+- Let the compiler identify all creation sites that need updating
3030+- Updated each site systematically
3131+- Ran `dune build` frequently to catch errors early
3232+3333+#### 2. Test Data Defaults
3434+For test nodes, used sensible defaults:
3535+- `description = "test commit"`
3636+- `bookmarks = []`
3737+- `author_email = "test@example.com"`
3838+- `author_timestamp = "2024-01-01T00:00:00Z"`
3939+- `empty = false`
4040+- `hidden = false`
4141+- `divergent = false`
4242+- `is_preview = false`
4343+4444+#### 3. Real Data Population (jj_json.ml)
4545+Mapped from jj_commit fields:
4646+- `description = jj_commit.description`
4747+- `bookmarks = jj_commit.bookmarks`
4848+- `author_email = jj_commit.author.email`
4949+- `author_timestamp = jj_commit.author.timestamp`
5050+- `empty = jj_commit.empty`
5151+- `hidden = jj_commit.hidden`
5252+- `divergent = jj_commit.divergent`
5353+- `is_preview = false` (always false for real commits, will be true for preview nodes)
5454+5555+#### 4. LSP Behavior
5656+- LSP showed errors during incremental updates (expected)
5757+- Errors cleared after running `dune build`
5858+- Final LSP diagnostics were clean after all changes
5959+6060+### Verification Results
6161+✅ `dune build` - SUCCESS (only warnings, no errors)
6262+✅ `dune runtest` - SUCCESS (all tests pass)
6363+✅ LSP diagnostics - CLEAN (no errors in modified files)
6464+6565+### Impact on Existing Code
6666+- No changes to graph rendering logic
6767+- No changes to test expectations (graph output unchanged)
6868+- All existing tests continue to pass
6969+- Type extension is backward compatible (only adds fields)
7070+7171+### Next Steps
7272+This completes Task 2.1. The node type now has all fields needed for:
7373+- Display functionality (description, bookmarks, author info)
7474+- Preview support (is_preview flag)
7575+- Additional metadata (empty, hidden, divergent)
7676+7777+Ready for Task 2.2: Update graph rendering to use new fields.
7878+7979+---
8080+8181+# Learnings - Task 2.2: Add elided revision support
8282+8383+## Task Completed: 2026-01-15
8484+8585+### What Was Done
8686+Added infrastructure for elided revisions - special nodes representing skipped commits in the graph display (shown as `~` in jj's output).
8787+8888+### Files Modified
8989+1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph.ml` - Added elided node functions
9090+2. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph_tests.ml` - Added 3 tests for elided nodes
9191+9292+### Functions Added
9393+```ocaml
9494+(** Special marker for elided nodes *)
9595+let elided_marker = "~ELIDED~"
9696+9797+(** Create a special node representing an elided section *)
9898+let make_elided_node () : node
9999+100100+(** Check if a node represents an elided section *)
101101+let is_elided (n : node) : bool
102102+```
103103+104104+### Implementation Details
105105+106106+#### Elided Node Marker
107107+- Used special string `"~ELIDED~"` as marker in both `commit_id` and `change_id` fields
108108+- This makes elided nodes easily identifiable and prevents confusion with real commits
109109+- Marker is unlikely to collide with actual commit IDs
110110+111111+#### Elided Node Properties
112112+- `parents = []` - Elided nodes don't track parent relationships
113113+- `creation_time = Int64.zero` - No meaningful timestamp
114114+- `working_copy = false`, `immutable = false`, `wip = false` - Not a real commit
115115+- `commit_id = elided_marker`, `change_id = elided_marker` - Special marker
116116+- `description = "(elided revisions)"` - Human-readable description
117117+- `bookmarks = []` - No bookmarks
118118+- `author_email = ""`, `author_timestamp = ""` - No author info
119119+- `empty = false` - Not technically empty
120120+- `hidden = true` - Conceptually hidden (elided means skipped)
121121+- `divergent = false`, `is_preview = false` - Not divergent or preview
122122+123123+#### Detection Function
124124+- `is_elided` checks if `commit_id` equals `elided_marker`
125125+- Simple and efficient O(1) check
126126+- Works because elided_marker is unique and won't appear in real commits
127127+128128+### Tests Added
129129+1. **make_elided_node** - Verifies elided node creation with correct marker and properties
130130+2. **is_elided_true** - Verifies `is_elided` returns true for elided nodes
131131+3. **is_elided_false** - Verifies `is_elided` returns false for normal nodes
132132+133133+### Verification Results
134134+✅ `dune build` - SUCCESS (only warnings, no errors)
135135+✅ `dune runtest` - SUCCESS (all tests pass, including 3 new elided node tests)
136136+✅ LSP diagnostics - CLEAN (no errors in modified files)
137137+138138+### Key Patterns Observed
139139+140140+#### 1. Special Node Pattern
141141+- Used a special marker string to identify elided nodes
142142+- Alternative approaches considered:
143143+ - Variant type: Would require changing node type everywhere
144144+ - Optional field: Would add complexity to all node handling
145145+ - Special marker: Simple, backward compatible, easy to check
146146+147147+#### 2. Test Coverage
148148+- Tested creation (make_elided_node)
149149+- Tested positive detection (is_elided on elided node)
150150+- Tested negative detection (is_elided on normal node)
151151+- This covers all code paths for the new functions
152152+153153+#### 3. No .mli File
154154+- `render_jj_graph.ml` has no corresponding `.mli` file
155155+- All functions are automatically exported
156156+- No need to update module signature
157157+158158+### Impact on Existing Code
159159+- No changes to existing functions or types
160160+- No changes to graph rendering logic (that's a later task)
161161+- All existing tests continue to pass
162162+- New functions are additive only
163163+164164+### Next Steps
165165+This completes Task 2.2. The infrastructure for elided nodes is now in place:
166166+- Functions to create elided nodes
167167+- Functions to identify elided nodes
168168+- Tests to verify behavior
169169+170170+Ready for Task 2.3: Create structured output type for UI integration.
171171+172172+### Notes for Future Tasks
173173+- Elided nodes should render as `~` followed by blank line (per plan line 156-160)
174174+- Actual rendering logic will be implemented in a later task
175175+- The `hidden = true` property may be useful for filtering or special rendering
176176+177177+---
178178+179179+# Learnings - Task 2.3: Create structured output types
180180+181181+## Task Completed: 2026-01-15
182182+183183+### What Was Done
184184+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.
185185+186186+### Files Modified
187187+1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph.ml` - Added types and function
188188+2. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/render_jj_graph_tests.ml` - Added 2 tests
189189+190190+### Types Added
191191+```ocaml
192192+type row_type =
193193+ | NodeRow (** The main row with the node glyph *)
194194+ | LinkRow (** Merge/fork connector lines *)
195195+ | PadRow (** Padding/continuation lines *)
196196+ | TermRow (** Termination lines with ~ *)
197197+198198+type graph_row_output = {
199199+ graph_chars : string (** The graph prefix like "○ " or "├─╮" *)
200200+ ; node : node (** The node this row represents *)
201201+ ; row_type : row_type (** What kind of row this is *)
202202+}
203203+```
204204+205205+### Function Added
206206+```ocaml
207207+val render_nodes_structured :
208208+ ?info_lines:(node -> int) ->
209209+ state ->
210210+ node list ->
211211+ graph_row_output list
212212+```
213213+214214+### Implementation Details
215215+216216+#### Row Type Classification
217217+Created `classify_row_type` helper function that detects row type based on content:
218218+- **NodeRow**: Contains node glyphs (○, @, ◌, ◆)
219219+- **TermRow**: Contains termination marker (~)
220220+- **LinkRow**: Contains merge/fork characters (├, ╮, ╯, ╰, ┬, ┴, ┼)
221221+- **PadRow**: Everything else (vertical lines, spaces)
222222+223223+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.
224224+225225+#### Structured Rendering Function
226226+`render_nodes_structured` mirrors the logic of `render_nodes_to_string` but:
227227+1. Builds a list of `graph_row_output` records instead of concatenating strings
228228+2. Classifies each line using `classify_row_type`
229229+3. Associates each line with its corresponding node
230230+4. Returns the list in correct order (reversed at the end since we build it backwards)
231231+232232+The function handles:
233233+- Extra pad lines from previous rows
234234+- Node lines (main commit row)
235235+- Link lines (merge/fork connectors)
236236+- Term lines (two lines for termination)
237237+- Info lines (additional content lines per node)
238238+- Final extra pad line
239239+240240+### Tests Added
241241+1. **render_nodes_structured_simple** - Tests basic 2-node graph with info_lines
242242+ - Verifies correct number of rows
243243+ - Verifies each row has correct type, node, and graph_chars
244244+245245+2. **render_nodes_structured_row_types** - Tests complex merge graph
246246+ - Verifies row type classification (NodeRow, LinkRow, PadRow)
247247+ - Counts each type to ensure correct classification
248248+ - Tests the merge topology from existing golden test
249249+250250+### Verification Results
251251+✅ `dune build` - SUCCESS (only warnings, no errors)
252252+✅ `dune runtest` - SUCCESS (all tests pass, including 2 new structured output tests)
253253+✅ LSP diagnostics - CLEAN (no errors in modified files)
254254+255255+### Key Patterns Observed
256256+257257+#### 1. UTF-8 String Searching
258258+OCaml's `String.contains` only works with single-byte characters. For UTF-8 glyphs, used:
259259+```ocaml
260260+let contains_str s substr =
261261+ try
262262+ let _ = Str.search_forward (Str.regexp_string substr) s 0 in
263263+ true
264264+ with Not_found -> false
265265+```
266266+267267+#### 2. Mirroring Existing Logic
268268+The `render_nodes_structured` function closely mirrors `render_nodes_to_string`:
269269+- Same structure and flow
270270+- Same handling of extra_pad_line_ref
271271+- Same rendering of node_line, link_line, term_line, pad_lines
272272+- Only difference: builds structured list instead of string buffer
273273+274274+This ensures consistency and makes it easy to verify correctness by comparing outputs.
275275+276276+#### 3. Backward Compatibility
277277+- Kept existing `render_nodes_to_string` function unchanged
278278+- New function is additive only
279279+- All existing tests continue to pass
280280+- No breaking changes to public API
281281+282282+#### 4. Test Expectations
283283+- Initial test expectations needed adjustment (dune promote)
284284+- Final pad line is not included when empty (expected behavior)
285285+- Tests verify both structure (row count, types) and content (graph_chars)
286286+287287+### Impact on Existing Code
288288+- No changes to existing functions
289289+- No changes to graph rendering logic
290290+- All existing tests continue to pass
291291+- New types and function are additive only
292292+293293+### Next Steps
294294+This completes Task 2.3. The structured output infrastructure is now in place:
295295+- Types to represent different row types
296296+- Function to generate structured output
297297+- Tests to verify behavior
298298+299299+Ready for Task 2.4: Update existing tests (if needed).
300300+301301+### Notes for Future Tasks
302302+- The `graph_row_output` type can be extended with additional fields if needed
303303+- The `classify_row_type` function could be made more sophisticated if needed
304304+- The UI can now consume structured output and style different row types differently
305305+- Each row is associated with its node, enabling hover/selection features
306306+307307+---
308308+309309+# Learnings - Task 3.1: Add JSON-based graph fetching functions
310310+311311+## Task Completed: 2026-01-15
312312+313313+### What Was Done
314314+Added two new functions to `process_wrappers.ml` for JSON-based graph fetching using the jj_json module.
315315+316316+### Files Modified
317317+1. `/home/eli/Code/ocaml/jj_tui/jj_tui/lib/process_wrappers.ml` - Added get_graph_json and get_graph_nodes
318318+319319+### Functions Added
320320+```ocaml
321321+(** Fetch graph data as JSON and parse into commits *)
322322+val get_graph_json :
323323+ ?revset:string ->
324324+ int -> (* limit *)
325325+ Jj_json.jj_commit list
326326+327327+(** Fetch and convert to renderer nodes *)
328328+val get_graph_nodes :
329329+ ?revset:string ->
330330+ int ->
331331+ (Render_jj_graph.node list * string maybe_unique array)
332332+```
333333+334334+### Implementation Details
335335+336336+#### get_graph_json Function
337337+Executes `jj log --no-graph --color never -T <json_template> --limit <n> [revset]` and parses the JSONL output:
338338+- Builds command args list with JSON template from `Jj_json.json_log_template`
339339+- Handles optional revset parameter by appending `-r <revset>` to args
340340+- Calls `jj_no_log` to execute the command (no snapshot needed for read-only operation)
341341+- Parses output using `Jj_json.parse_jj_log_output`
342342+- Returns list of `Jj_json.jj_commit` or fails with descriptive error message
343343+344344+#### get_graph_nodes Function
345345+Converts commits to renderer nodes and extracts rev_ids for selection tracking:
346346+- Calls `get_graph_json` to fetch commits
347347+- Converts to nodes using `Jj_json.commits_to_nodes`
348348+- Extracts rev_ids as `string maybe_unique array`:
349349+ - `Duplicate commit_id` if commit is divergent or hidden
350350+ - `Unique change_id` otherwise
351351+- Returns tuple of `(node list, string maybe_unique array)`
352352+353353+### Key Patterns Observed
354354+355355+#### 1. Command Execution Pattern
356356+Followed the same pattern as `graph_and_revs`:
357357+- Build args list incrementally
358358+- Handle optional revset with pattern matching
359359+- Use `jj_no_log` for execution (no snapshot for read-only commands)
360360+- No async/promise needed for simple synchronous operations
361361+362362+#### 2. Rev_id Type Clarification
363363+The plan mentioned `Global_vars.rev_id array` but the actual type is `string maybe_unique array`:
364364+- `rev_id` is defined in `Process` module as a record with change_id, commit_id, divergent
365365+- The UI layer uses `string maybe_unique` where the string is either change_id or commit_id
366366+- `maybe_unique` is a variant: `Unique of 'a | Duplicate of 'a`
367367+- Divergent or hidden commits use `Duplicate commit_id`
368368+- Normal commits use `Unique change_id`
369369+370370+This matches the pattern in the existing `find_selectable_from_graph` function (lines 80-82).
371371+372372+#### 3. Error Handling
373373+Used simple `failwith` for JSON parsing errors since:
374374+- This is a critical error that should stop execution
375375+- The error message includes the parse error details
376376+- Matches the error handling pattern in the codebase
377377+- Alternative would be to return Result type, but that's not the pattern here
378378+379379+#### 4. Functor Pattern
380380+Functions added inside the `Make` functor:
381381+- Have access to `jj_no_log` from the Process parameter
382382+- Follow the same structure as other functions in the module
383383+- No need to pass Process explicitly
384384+385385+### Verification Results
386386+✅ `dune build` - SUCCESS (only warnings, no errors)
387387+✅ LSP diagnostics - CLEAN (no errors in process_wrappers.ml)
388388+389389+### Impact on Existing Code
390390+- No changes to existing functions
391391+- `graph_and_revs` remains unchanged (kept for backward compatibility)
392392+- New functions are additive only
393393+- No breaking changes to public API
394394+395395+### Next Steps
396396+This completes Task 3.1. The JSON-based graph fetching functions are now in place:
397397+- `get_graph_json` fetches and parses JSON commits
398398+- `get_graph_nodes` converts to renderer nodes with rev_ids
399399+- Both functions ready for integration in graph view
400400+401401+Ready for Phase 4: Graph View Integration.
402402+403403+### Notes for Future Tasks
404404+- The new functions use the same command execution pattern as existing code
405405+- Rev_ids are extracted in the same format as the old graph_and_revs function
406406+- The functions can be used as drop-in replacements once the UI is updated
407407+- Consider adding tests for these functions in a future task
+364
.sisyphus/plans/integrate-graph-renderer.md
···11+# Plan: Integrate New Graph Renderer into jj_tui
22+33+## Overview
44+55+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:
66+1. Full control over graph rendering
77+2. Ability to insert "preview" nodes before/after any node for move/rebase previews
88+3. Cleaner separation between data fetching and rendering
99+1010+## Architecture
1111+1212+```
1313+Current Flow:
1414+ jj log (ASCII graph + markers) → regex parse → display rows
1515+1616+New Flow:
1717+ jj log --no-graph --color never (JSON) → parse JSON → build node list → render_jj_graph → display
1818+ ↓
1919+ [insert preview nodes here]
2020+```
2121+2222+## Design Decisions
2323+2424+- **Node type**: Flat record with `is_preview` flag (not a variant)
2525+- **Styling**: Custom styling (to be decided later, not matching jj)
2626+- **Elided revisions**: Render as `~` with one-line gap, matching jj's format
2727+2828+---
2929+3030+## Phase 1: JSON Data Layer
3131+3232+**Goal**: Create a module to fetch and parse jj log output as JSON.
3333+3434+### Task 1.1: Create `jj_tui/lib/jj_json.ml`
3535+3636+New module for JSON types and parsing.
3737+3838+**Types to define:**
3939+4040+```ocaml
4141+type jj_author = {
4242+ email : string
4343+ ; timestamp : string
4444+}
4545+[@@deriving yojson]
4646+4747+type jj_commit = {
4848+ commit_id : string
4949+ ; parents : string list
5050+ ; change_id : string
5151+ ; description : string
5252+ ; working_copy : bool
5353+ ; immutable : bool
5454+ ; wip : bool
5555+ ; hidden : bool
5656+ ; divergent : bool
5757+ ; empty : bool
5858+ ; bookmarks : string list
5959+ ; author : jj_author
6060+}
6161+[@@deriving yojson]
6262+```
6363+6464+**Functions to implement:**
6565+6666+```ocaml
6767+(** The jj template that produces JSONL output *)
6868+val json_log_template : string
6969+7070+(** Parse JSONL (one JSON object per line) from jj log output *)
7171+val parse_jj_log_output : string -> (jj_commit list, string) result
7272+7373+(** Convert list of jj_commit to render_jj_graph.node list.
7474+ Uses two-pass approach: create nodes, then link parents. *)
7575+val commits_to_nodes : jj_commit list -> Render_jj_graph.node list
7676+```
7777+7878+**JSON template string:**
7979+8080+```
8181+'{'
8282+ ++ '"commit_id":' ++ json(commit_id)
8383+ ++ ',"parents":[' ++ parents.map(|c| json(c.commit_id())).join(",") ++ ']'
8484+ ++ ',"change_id":' ++ json(change_id)
8585+ ++ ',"description":' ++ json(description)
8686+ ++ ',"working_copy":' ++ json(current_working_copy)
8787+ ++ ',"immutable":' ++ json(immutable)
8888+ ++ ',"wip":' ++ json(description.first_line().starts_with("wip:"))
8989+ ++ ',"hidden":' ++ json(hidden)
9090+ ++ ',"divergent":' ++ json(divergent)
9191+ ++ ',"empty":' ++ json(empty)
9292+ ++ ',"bookmarks":[' ++ bookmarks.map(|b| json(b.name())).join(",") ++ ']'
9393+ ++ ',"author":{"email":' ++ json(author.email()) ++ ',"timestamp":' ++ json(author.timestamp()) ++ '}'
9494+ ++ '}
9595+'
9696+```
9797+9898+### Task 1.2: Update `jj_tui/lib/dune`
9999+100100+Add `jj_json` module and ensure `yojson` dependency is available (already in project for tests).
101101+102102+### Task 1.3: Write tests for `jj_json.ml`
103103+104104+Create `jj_tui/lib/jj_json_tests.ml` with:
105105+- Test parsing valid JSONL
106106+- Test handling missing parents gracefully (root commits)
107107+- Test node linking produces correct parent references
108108+109109+---
110110+111111+## Phase 2: Extend Graph Renderer
112112+113113+**Goal**: Enhance `render_jj_graph.ml` to support richer output needed for UI integration.
114114+115115+### Task 2.1: Extend `node` type in `render_jj_graph.ml`
116116+117117+Add fields needed for display and preview functionality:
118118+119119+```ocaml
120120+type node = {
121121+ (* Existing fields *)
122122+ parents : node list
123123+ ; creation_time : int64
124124+ ; working_copy : bool
125125+ ; immutable : bool
126126+ ; wip : bool
127127+ ; change_id : string
128128+ ; commit_id : string
129129+ (* New fields for display *)
130130+ ; description : string
131131+ ; bookmarks : string list
132132+ ; author_email : string
133133+ ; author_timestamp : string
134134+ ; empty : bool
135135+ ; hidden : bool
136136+ ; divergent : bool
137137+ (* Preview support *)
138138+ ; is_preview : bool
139139+}
140140+```
141141+142142+**Note**: Update `jj_json.ml` conversion function to populate all fields.
143143+144144+### Task 2.2: Add elided revision support
145145+146146+Add a way to represent elided sections in the graph:
147147+148148+```ocaml
149149+(** Special node type for elided revisions *)
150150+val make_elided_node : unit -> node
151151+152152+(** Check if a node represents an elided section *)
153153+val is_elided : node -> bool
154154+```
155155+156156+Elided nodes should render as:
157157+```
158158+~
159159+```
160160+(tilde followed by blank line, matching jj's format)
161161+162162+### Task 2.3: Create structured output type
163163+164164+Instead of returning strings, return structured data for UI integration:
165165+166166+```ocaml
167167+type graph_row_output = {
168168+ graph_chars : string (* The graph prefix like "○ " or "├─╮" *)
169169+ ; node : node (* The node this row represents *)
170170+ ; row_type : row_type (* What kind of row this is *)
171171+}
172172+173173+and row_type =
174174+ | NodeRow (* The main row with the node glyph *)
175175+ | LinkRow (* Merge/fork connector lines *)
176176+ | PadRow (* Padding/continuation lines *)
177177+ | TermRow (* Termination lines with ~ *)
178178+179179+(** Render nodes to structured output for UI integration *)
180180+val render_nodes_structured :
181181+ state ->
182182+ node list ->
183183+ info_lines:(node -> int) -> (* How many content lines per node *)
184184+ graph_row_output list
185185+```
186186+187187+### Task 2.4: Update existing tests
188188+189189+Ensure all existing golden tests in `render_jj_graph_tests.ml` still pass after type changes.
190190+191191+---
192192+193193+## Phase 3: Process Layer Integration
194194+195195+**Goal**: Add new functions to `process_wrappers.ml` for JSON-based graph fetching.
196196+197197+### Task 3.1: Add `get_graph_json` function
198198+199199+```ocaml
200200+(** Fetch graph data as JSON and parse into commits *)
201201+val get_graph_json :
202202+ ?revset:string ->
203203+ int -> (* limit *)
204204+ Jj_json.jj_commit list
205205+206206+(** Fetch and convert to renderer nodes *)
207207+val get_graph_nodes :
208208+ ?revset:string ->
209209+ int ->
210210+ (Render_jj_graph.node list * Global_vars.rev_id array)
211211+```
212212+213213+**Implementation notes:**
214214+- Call `jj log --no-graph --color never -T <json_template> --limit <n> [revset]`
215215+- Parse JSONL output
216216+- Convert to nodes
217217+- Extract rev_ids for selection tracking
218218+219219+### Task 3.2: Keep old `graph_and_revs` working
220220+221221+Don't remove the old function yet - keep it for fallback/comparison during development.
222222+223223+---
224224+225225+## Phase 4: Graph View Integration
226226+227227+**Goal**: Update `graph_view.ml` to use the new renderer.
228228+229229+### Task 4.1: Create `render_commit_content` function
230230+231231+Render the text content for a node (styling TBD):
232232+233233+```ocaml
234234+val render_commit_content : Render_jj_graph.node -> Notty.image
235235+```
236236+237237+Basic implementation (styling to be refined later):
238238+- Show change_id (first 8 chars)
239239+- Show author email
240240+- Show timestamp
241241+- Show description first line (or "(no description set)")
242242+- Show bookmarks if any
243243+- Different appearance for working_copy, immutable, empty, preview nodes
244244+245245+### Task 4.2: Create `render_graph_row` function
246246+247247+Combine graph prefix with content:
248248+249249+```ocaml
250250+val render_graph_row :
251251+ Render_jj_graph.graph_row_output ->
252252+ render_content:(Render_jj_graph.node -> Notty.image) ->
253253+ Notty.image
254254+```
255255+256256+### Task 4.3: Update `graph_view` function
257257+258258+Replace the current flow:
259259+260260+```ocaml
261261+(* OLD *)
262262+let graph, rev_ids = graph_and_revs ?revset max_commits () in
263263+(* process graph which is array of `Selectable string | `Filler string *)
264264+265265+(* NEW *)
266266+let nodes, rev_ids = get_graph_nodes ?revset max_commits in
267267+let rendered_rows = Render_jj_graph.render_nodes_structured ... nodes in
268268+(* Convert to list items, distinguishing Selectable from Filler based on row_type *)
269269+```
270270+271271+### Task 4.4: Handle elided revisions in UI
272272+273273+Elided nodes should appear as `Filler` items (non-selectable) in the list widget.
274274+275275+---
276276+277277+## Phase 5: Preview Node Support
278278+279279+**Goal**: Enable inserting preview nodes for rebase/move visualization.
280280+281281+### Task 5.1: Add preview insertion functions
282282+283283+In `render_jj_graph.ml` or a new `graph_preview.ml`:
284284+285285+```ocaml
286286+(** Create a preview node *)
287287+val make_preview_node :
288288+ label:string ->
289289+ ?target_commit_id:string ->
290290+ unit ->
291291+ node
292292+293293+(** Insert a preview node after the specified commit *)
294294+val insert_preview_after :
295295+ nodes:node list ->
296296+ after_commit_id:string ->
297297+ preview:node ->
298298+ node list
299299+300300+(** Insert a preview node before the specified commit *)
301301+val insert_preview_before :
302302+ nodes:node list ->
303303+ before_commit_id:string ->
304304+ preview:node ->
305305+ node list
306306+```
307307+308308+### Task 5.2: Visual distinction for preview nodes
309309+310310+In `render_commit_content`, handle `is_preview = true` nodes:
311311+- Use different glyph (e.g., `◇` or `?`)
312312+- Use distinct styling (e.g., dim, italic)
313313+- Show preview label instead of commit info
314314+315315+### Task 5.3: Integration with commands
316316+317317+Wire up preview display for commands like:
318318+- Rebase preview: Show where commits would land
319319+- Move preview: Show destination
320320+321321+(Specific command integration TBD based on UX decisions)
322322+323323+---
324324+325325+## Files Summary
326326+327327+| File | Action | Description |
328328+|------|--------|-------------|
329329+| `lib/jj_json.ml` | **Create** | JSON types, parsing, node conversion |
330330+| `lib/jj_json_tests.ml` | **Create** | Unit tests for JSON parsing |
331331+| `lib/render_jj_graph.ml` | **Modify** | Extend node type, add structured output |
332332+| `lib/render_jj_graph_tests.ml` | **Modify** | Update tests for new node fields |
333333+| `lib/process_wrappers.ml` | **Modify** | Add JSON-based graph fetching |
334334+| `bin/graph_view.ml` | **Modify** | Use new renderer, add content rendering |
335335+| `lib/dune` | **Modify** | Add jj_json module |
336336+337337+---
338338+339339+## Implementation Order
340340+341341+1. **Phase 1**: Create `jj_json.ml` - can be tested independently
342342+2. **Phase 2**: Extend `render_jj_graph.ml` - update types and add structured output
343343+3. **Phase 3**: Update `process_wrappers.ml` - add JSON fetching
344344+4. **Phase 4**: Update `graph_view.ml` - integrate everything
345345+5. **Phase 5**: Add preview node support
346346+347347+Each phase should be testable independently before moving to the next.
348348+349349+---
350350+351351+## Testing Strategy
352352+353353+1. **Unit tests**: JSON parsing, node conversion, graph rendering
354354+2. **Golden tests**: Existing tests in `render_jj_graph_tests.ml` should continue passing
355355+3. **Integration test**: Run the TUI and verify graph displays correctly
356356+4. **Manual testing**: Compare output visually with `jj log` output
357357+358358+---
359359+360360+## Open Questions (for later)
361361+362362+1. **Styling**: What colors/styles to use for different node types?
363363+2. **Preview UX**: How should preview nodes be triggered and displayed?
364364+3. **Performance**: Is JSON parsing fast enough for large repos? (Probably fine with limits)
+301
.sisyphus/plans/jj-graph-renderer.md
···11+# JJ Graph Renderer Implementation Plan
22+33+**Created**: 2026-01-15
44+**Status**: Ready for implementation
55+66+---
77+88+## Summary
99+1010+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).
1111+1212+---
1313+1414+## Non-negotiables
1515+1616+- **Do not change golden test outputs** in `jj_tui/lib/render_jj_graph_tests.ml`
1717+- **Preserve the public API that tests compile against**:
1818+ - `type node = { parents : node list; creation_time : int64; working_copy : bool; immutable : bool; wip : bool; change_id : string; commit_id : string }`
1919+ - `type state = { depth : int; columns : _ array; pending_joins : _ list }`
2020+ - `render_nodes_to_string : ?info_rows:(node -> int) -> state -> node list -> string`
2121+2222+---
2323+2424+## Key Risk / Design Constraint
2525+2626+The Rust reference renderer's box drawing output uses **2 chars per column cell** (`"│ "`, `"╭─"`, etc). The OCaml golden tests encode a **different spacing convention**.
2727+2828+**Strategy**:
2929+- Port the **topology → intermediate representation** (columns + link flags) 1:1 from Rust
3030+- Implement OCaml box-drawing formatter that produces **exactly the OCaml golden output format**
3131+3232+---
3333+3434+## Architecture
3535+3636+```
3737+┌─────────────────────────────────────────────────────────────┐
3838+│ API Layer │
3939+│ render_nodes_to_string : state -> node list -> string │
4040+│ render_nodes_to_ui : state -> node list -> Notty.image │
4141+└─────────────────────────────────────────────────────────────┘
4242+ │
4343+ ▼
4444+┌─────────────────────────────────────────────────────────────┐
4545+│ GraphRowRenderer │
4646+│ - Maintains column state │
4747+│ - Assigns nodes to columns │
4848+│ - Produces GraphRow records │
4949+└─────────────────────────────────────────────────────────────┘
5050+ │
5151+ ▼
5252+┌─────────────────────────────────────────────────────────────┐
5353+│ BoxDrawingRenderer │
5454+│ - Converts GraphRow → string/image │
5555+│ - Selects glyphs based on LinkLine flags │
5656+│ - Handles pad lines, term lines │
5757+└─────────────────────────────────────────────────────────────┘
5858+```
5959+6060+---
6161+6262+## Tasks
6363+6464+### Task 1: Lock down existing compilation surface
6565+6666+**Goal**: Ensure tests compile unchanged
6767+6868+**Actions**:
6969+- Leave `node` and `state` types in `render_jj_graph.ml` as tests expect
7070+- `state.depth`, `state.pending_joins` may become unused but must exist
7171+- `state.columns` may be repurposed as renderer's column state
7272+7373+**Deliverable**: `render_jj_graph_tests.ml` compiles unchanged
7474+7575+---
7676+7777+### Task 2: Implement internal type equivalents
7878+7979+**Goal**: Create OCaml versions of Rust types
8080+8181+**Types to add** (can be nested modules in `render_jj_graph.ml`):
8282+8383+```ocaml
8484+(* Column state *)
8585+type 'a column = Empty | Blocked | Reserved of 'a | Ancestor of 'a | Parent of 'a
8686+8787+(* Ancestor specification for parents *)
8888+type ancestor = AncestorOf of node | ParentOf of node | Anonymous
8989+(* Note: Anonymous treated as direct for is_direct() like Rust *)
9090+9191+(* Row element types *)
9292+type node_line_entry = NL_Blank | NL_Ancestor | NL_Parent | NL_Node
9393+type pad_line_entry = PL_Blank | PL_Ancestor | PL_Parent
9494+9595+(* LinkLine as int bitset *)
9696+module LinkLine : sig
9797+ type t = int
9898+ val empty : t
9999+ val ( lor ) : t -> t -> t
100100+ val intersects : t -> t -> bool
101101+102102+ (* Bit constants *)
103103+ val horiz_parent : t (* 0x0001 *)
104104+ val horiz_ancestor : t (* 0x0002 *)
105105+ val vert_parent : t (* 0x0004 *)
106106+ val vert_ancestor : t (* 0x0008 *)
107107+ val left_fork_parent : t (* 0x0010 *)
108108+ val left_fork_ancestor : t (* 0x0020 *)
109109+ val right_fork_parent : t (* 0x0040 *)
110110+ val right_fork_ancestor : t (* 0x0080 *)
111111+ val left_merge_parent : t (* 0x0100 *)
112112+ val left_merge_ancestor : t (* 0x0200 *)
113113+ val right_merge_parent : t (* 0x0400 *)
114114+ val right_merge_ancestor : t (* 0x0800 *)
115115+ val child : t (* 0x1000 *)
116116+117117+ (* Compound flags *)
118118+ val horizontal : t
119119+ val vertical : t
120120+ val left_fork : t
121121+ val right_fork : t
122122+ val left_merge : t
123123+ val right_merge : t
124124+ val any_merge : t
125125+ val any_fork : t
126126+end
127127+128128+(* Intermediate row representation *)
129129+type graph_row = {
130130+ row_node : node
131131+; glyph : Uchar.t
132132+; message : string (* empty for now, ready for future text *)
133133+; merge : bool
134134+; node_line : node_line_entry array
135135+; link_line : LinkLine.t array option
136136+; term_line : bool array option
137137+; pad_lines : pad_line_entry array
138138+}
139139+```
140140+141141+**Deliverable**: Compiles, no formatting yet
142142+143143+---
144144+145145+### Task 3: Column utility functions
146146+147147+**Goal**: Implement Rust `ColumnsExt` trait equivalent
148148+149149+**Functions**:
150150+```ocaml
151151+val column_matches : node column -> node -> bool
152152+val column_variant : _ column -> int (* for merge priority *)
153153+val column_merge : node column -> node column -> node column
154154+155155+val columns_find : node column array -> node -> int option
156156+val columns_first_empty : node column array -> int option
157157+val columns_find_empty : node column array -> prefer:int -> int option
158158+val columns_new_empty : node column array ref -> int
159159+val columns_reset : node column array ref -> unit
160160+ (* Blocked → Empty, trim trailing Empty *)
161161+```
162162+163163+**Priority order for merge**: Parent(4) > Ancestor(3) > Reserved(2) > Blocked(1) > Empty(0)
164164+165165+**Deliverable**: Unit correctness by inspection; used by renderer
166166+167167+---
168168+169169+### Task 4: GraphRowRenderer.next_row algorithm
170170+171171+**Goal**: Translate Rust core algorithm to OCaml
172172+173173+**Algorithm steps**:
174174+175175+1. **Determine target column for node**:
176176+ - If column already reserved for it, use it
177177+ - Else use first empty or append
178178+ - Clear target to Empty before assigning parents
179179+180180+2. **Initialize row arrays from current columns**:
181181+ - `node_line` from column state (Ancestor/Parent → vertical markers; others blank)
182182+ - `link_line` similarly
183183+ - `term_line` all false
184184+ - `pad_lines` similarly
185185+186186+3. **Assign parent columns**:
187187+ - If parent already has a column, merge into it
188188+ - Else try `find_empty` preferring current node column
189189+ - Else append new column and extend row arrays in sync
190190+191191+4. **Mark anonymous parents as terminations** (`term_line[i] = true`)
192192+193193+5. **Single-parent swap optimization**:
194194+ - If exactly one parent and parent column > node column
195195+ - Swap columns and emit fork/merge link flags
196196+197197+6. **Connect node column to all parent columns**:
198198+ - Compute bounds (min/max ancestor/parent columns)
199199+ - Fill horizontal segments between outer bounds
200200+ - Set left/right merge markers on node col if needed
201201+ - Set fork markers per parent column
202202+203203+7. **Reset columns** (Blocked cleanup + trailing trim)
204204+205205+8. **Filter optional lines** (only keep link_line/term_line if needed)
206206+207207+**Deliverable**: Working topology engine producing stable row structures
208208+209209+---
210210+211211+### Task 5: BoxDrawing formatting (matching OCaml golden outputs)
212212+213213+**Goal**: Convert GraphRow to string matching test expectations exactly
214214+215215+**Glyph mapping** (use existing `P` module constants):
216216+- `P.v` = `│`, `P.h` = `─`
217217+- `P.vr` = `├`, `P.vl` = `┤`, `P.t` = `┬`, `P.b` = `┴`, `P.cross` = `┼`
218218+- Elbows: `P.edl` = `╭`, `P.edr` = `╮`, `P.eul` = `╰`, `P.eur` = `╯`
219219+- `P.ancestor` = `·`, `P.sp` = ` `
220220+- Node glyphs: `P.Node.working_copy` = `@`, `P.Node.normal` = `○`, `P.Node.immutable` = `◆`, `P.Node.wip` = `◌`
221221+222222+**Glyph selection logic** (port from Rust `box_drawing.rs`):
223223+- 14 glyph types: SPACE, HORIZONTAL, PARENT, ANCESTOR, MERGE_LEFT/RIGHT/BOTH, FORK_LEFT/RIGHT/BOTH, JOIN_LEFT/RIGHT/BOTH, TERMINATION
224224+- Complex conditional based on LinkLine flags and `merge` bool
225225+226226+**Important**: Match OCaml golden test spacing, not Rust 2-wide cells
227227+228228+**Also implement**:
229229+- Termination rendering: `│` then `~` rows
230230+- `info_rows` support: insert additional pad rows after certain nodes
231231+232232+**Deliverable**: `render_nodes_to_string` matches all golden tests exactly
233233+234234+---
235235+236236+### Task 6: Notty UI output
237237+238238+**Goal**: Provide `render_nodes_to_ui` for TUI integration
239239+240240+**Approach (correctness-first)**:
241241+- First implementation: call `render_nodes_to_string`, convert to `Notty.image` via `I.string A.empty` with newline splitting
242242+- Later optimization (optional): render cell-wise with `I.uchar` and `I.hcat/I.vcat`
243243+244244+**Signature**:
245245+```ocaml
246246+val render_nodes_to_ui : ?info_rows:(node -> int) -> state -> node list -> Notty.image
247247+```
248248+249249+**Deliverable**: UI renderer visually matching string output
250250+251251+---
252252+253253+### Task 7: Additional tests (additive only)
254254+255255+**Goal**: Better coverage without modifying existing golden outputs
256256+257257+**New test cases**:
258258+- Linear chain (no merges, only `│` lines)
259259+- Single-parent swap scenario
260260+- Explicit anonymous termination behavior (`~`)
261261+- Multi-parent/octopus merge patterns
262262+263263+**Smoke test for UI**:
264264+- Ensure `render_nodes_to_ui` produces non-empty image for simple graph
265265+266266+**Deliverable**: Improved test coverage
267267+268268+---
269269+270270+## Verification Checklist
271271+272272+- [ ] `dune build` succeeds
273273+- [ ] `dune runtest` passes all tests in `jj_tui/lib/`
274274+- [ ] No changes to existing `%expect` blocks
275275+- [ ] `render_nodes_to_string` matches golden outputs exactly
276276+- [ ] `render_nodes_to_ui` exists and returns usable `Notty.image`
277277+278278+---
279279+280280+## File Changes
281281+282282+| File | Action |
283283+|------|--------|
284284+| `jj_tui/lib/render_jj_graph.ml` | Extend with full implementation (~500 LOC) |
285285+| `jj_tui/lib/render_jj_graph_tests.ml` | **No changes** (golden tests) |
286286+287287+---
288288+289289+## Future Work (out of scope for this plan)
290290+291291+- Node description text rendering
292292+- ANSI color support for graph lines
293293+- Performance optimization for large graphs
294294+295295+---
296296+297297+## Reference Materials
298298+299299+- Rust source: `docs/renderdagsrc.md` (column.rs, renderer.rs, box_drawing.rs)
300300+- Test data: `test/jj_log.json`
301301+- Existing glyphs: `P` module in `render_jj_graph.ml`
+263
AGENTS.md
···11+# AGENTS.md - Coding Agent Guidelines for jj_tui
22+33+> A terminal UI for the Jujutsu version control system, built in OCaml with Notty/Nottui.
44+55+## Project Structure
66+77+```
88+jj_tui/
99+├── bin/ # Main executable and UI components
1010+│ ├── main.ml # Entry point
1111+│ ├── jj_ui.ml # Main UI orchestration
1212+│ ├── graph_view.ml, graph_commands.ml # Commit graph UI
1313+│ ├── file_view.ml, file_commands.ml # File diff UI
1414+│ └── global_vars.ml # Shared state
1515+├── lib/ # Core library (jj_tui)
1616+│ ├── ansiReverse.ml # ANSI escape parsing
1717+│ ├── render_jj_graph.ml # Commit graph rendering
1818+│ ├── config.ml, key_map.ml # Configuration
1919+│ └── *_tests.ml # Inline tests
2020+├── test/lib/ # Additional test library (jj_tui_test)
2121+└── forks/ # Vendored dependencies (notty, nottui, lwd)
2222+```
2323+2424+## Build Commands
2525+2626+**Requires Nix** - This project uses Nix for dependency management.
2727+2828+```bash
2929+# Enter development shell (required first)
3030+nix develop
3131+3232+# Build
3333+dune build
3434+3535+# Build and watch
3636+dune build --watch
3737+3838+# Run the application
3939+dune exec jj_tui
4040+4141+# Run all tests
4242+dune runtest
4343+4444+# Run tests for specific library
4545+dune runtest -p jj_tui
4646+4747+# Run tests and show output
4848+dune runtest --force
4949+5050+# Format code
5151+dune fmt
5252+# or
5353+ocamlformat -i <file.ml>
5454+5555+# Check formatting without applying
5656+dune fmt --preview
5757+```
5858+5959+6060+6161+### Running Individual Tests
6262+6363+Tests use `ppx_expect` inline tests. To run tests in a specific file:
6464+6565+```bash
6666+# Run inline tests for jj_tui library
6767+dune runtest jj_tui/lib
6868+6969+# Run inline tests for test library
7070+dune runtest jj_tui/test/lib
7171+7272+# Promote expect test changes (update golden output)
7373+dune promote
7474+```
7575+## Key things to remember:
7676+- 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
7777+- 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.
7878+ You need to either:
7979+ - locally open the module with the type you want,
8080+ - or do Module.( myrecord.field)
8181+ - or explicitly annotate the type
8282+8383+## Code Style Guidelines
8484+8585+### OCamlformat Configuration
8686+8787+Uses `profile = janestreet` with customizations. Key settings:
8888+- `let-binding-spacing = double-semicolon` - End let bindings with `;;`
8989+- `break-cases = nested` - Multi-line match statements
9090+- `if-then-else = keyword-first` - Align `then`/`else`
9191+- `space-around-records/lists/arrays = true` - Spacing for trailing commas
9292+9393+### Import/Open Patterns
9494+9595+```ocaml
9696+(* Module-level opens at top of file *)
9797+open Lwd_infix
9898+open Notty
9999+open Nottui
100100+open Jj_tui
101101+open! Jj_tui.Util (* open! for shadowing *)
102102+103103+(* Functor-based module creation *)
104104+module Make (Vars : Global_vars.Vars) = struct
105105+ open Vars
106106+ module Process = Jj_process.Make (Vars)
107107+ open Process
108108+ (* ... *)
109109+end
110110+```
111111+112112+### Naming Conventions
113113+114114+- **Functions/values**: `snake_case` - `get_hovered_rev`, `parse_escape_seq`
115115+- **Types**: `snake_case` - `ui_state_t`, `rev_id`
116116+- **Modules**: `PascalCase` - `Internal`, `Parser`, `Key_Map`
117117+- **Type parameters**: `'a`, `'acc`, `'b`
118118+- **Record fields**: `snake_case` with semicolons
119119+- **Variant constructors**: `PascalCase` - `Unique`, `Duplicate`, `Apply`
120120+121121+### Type Definitions
122122+123123+```ocaml
124124+(* Record types - use semicolons before fields *)
125125+type t = {
126126+ key_map : Key_map.key_config [@updater]
127127+ ; single_pane_width_threshold : int
128128+ ; max_commits : int
129129+}
130130+[@@deriving yaml, record_updater ~derive:yaml]
131131+132132+(* Variant types *)
133133+type 'a maybe_unique =
134134+ | Unique of 'a
135135+ | Duplicate of 'a
136136+```
137137+138138+### Error Handling
139139+140140+```ocaml
141141+(* Prefer Result for parsing/fallible operations *)
142142+let of_string remap =
143143+ match remap with
144144+ | "up" -> Ok (`Arrow `Up)
145145+ | _ -> Error (`Msg ("Invalid remap: " ^ remap))
146146+147147+(* Use Option for optional values *)
148148+Sys.getenv_opt "XDG_CONFIG_HOME" |> Option.value ~default:"~/.config"
149149+150150+(* Exception handling for I/O *)
151151+try
152152+ let ic = open_in config_file in
153153+ (* ... *)
154154+with
155155+| Sys_error _ -> default_config
156156+| ex -> [%log warn "Error: %s" (Printexc.to_string ex)]; default_config
157157+```
158158+159159+### Lwd Operators (Reactive UI)
160160+161161+```ocaml
162162+let ( <-$ ) f v = Lwd.map ~f (Lwd.get v)
163163+let ( $-> ) v f = Lwd.map ~f (Lwd.get v)
164164+let ( let$$ ) v f = Lwd.map ~f (Lwd.get v)
165165+let ( |>$ ) v f = Lwd.map ~f v
166166+let ( >> ) f g x = g (f x) (* Compose left-to-right *)
167167+let ( << ) f g x = f (g x) (* Compose right-to-left *)
168168+169169+(* Usage *)
170170+let$ root = root in
171171+root |> Nottui.Ui.event_filter (...)
172172+```
173173+174174+### Logging
175175+176176+Uses `logs-ppx` with custom timestamp wrapper:
177177+178178+```ocaml
179179+open Jj_tui.Logging
180180+181181+[%log info "Loading config..."]
182182+[%log warn "Error parsing config: %s" msg]
183183+[%log debug "Old logs cleaned up"]
184184+```
185185+186186+### Testing (ppx_expect)
187187+188188+```ocaml
189189+let%expect_test "test_name" =
190190+ let result = some_function () in
191191+ print_endline result;
192192+ [%expect {|
193193+ expected output here
194194+ |}]
195195+;;
196196+```
197197+198198+### Documentation Comments
199199+200200+```ocaml
201201+(** Module-level documentation *)
202202+203203+(** Function documentation - concise, one line preferred *)
204204+let get_unique_id maybe_unique_rev = ...
205205+206206+(**
207207+ Multi-line documentation for complex functions.
208208+ Explains algorithm or non-obvious behavior.
209209+*)
210210+```
211211+212212+## Key Patterns
213213+214214+### Functor-Based Dependency Injection
215215+216216+```ocaml
217217+module Make (Vars : Global_vars.Vars) = struct
218218+ (* Access Vars.* throughout the module *)
219219+end
220220+```
221221+222222+### UI State Management
223223+224224+Global state in `Global_vars.Vars` using `Lwd.var`:
225225+226226+```ocaml
227227+type ui_state_t = {
228228+ view : [`Main | `Cmd_I of cmd_args | ...] Lwd.var
229229+ ; hovered_revision : string maybe_unique Lwd.var
230230+ (* ... *)
231231+}
232232+```
233233+234234+### Command Handling
235235+236236+Commands defined in `graph_commands.ml` / `file_commands.ml`:
237237+238238+```ocaml
239239+type command =
240240+ | Cmd of string list
241241+ | Cmd_async of string * string list
242242+ | Dynamic of (unit -> command)
243243+ | Selection_prompt of (...)
244244+```
245245+246246+## Dependencies
247247+248248+Key libraries:
249249+- **nottui** / **nottui_picos**: Terminal UI framework (forked)
250250+- **lwd** / **lwd_picos**: Reactive programming (forked)
251251+- **notty**: Terminal rendering (forked)
252252+- **angstrom**: Parser combinators
253253+- **picos**: Multicore/async runtime
254254+- **ppx_expect**: Inline testing
255255+- **ppx_deriving_yaml/yojson**: Serialization
256256+257257+## Common Pitfalls
258258+259259+1. **End `let` bindings with `;;`** - Required by ocamlformat config
260260+2. **Use Lwd operators** - Don't call `Lwd.get` directly in render functions
261261+3. **Functor pattern** - Most modules require `Make(Vars)` instantiation
262262+4. **Vendored forks** - Don't modify files in `forks/` unless necessary
263263+5. **Nix required** - Build system not set up for pure opam/dune
+1335
docs/renderdagsrc.md
···11+column.rs
22+```rs
33+/*
44+ * Copyright (c) Meta Platforms, Inc. and affiliates.
55+ *
66+ * This source code is licensed under the MIT license found in the
77+ * LICENSE file in the root directory of this source tree.
88+ */
99+1010+#[derive(Clone, Debug, PartialEq, Eq)]
1111+pub(crate) enum Column<N> {
1212+ Empty,
1313+ Blocked,
1414+ Reserved(N),
1515+ Ancestor(N),
1616+ Parent(N),
1717+}
1818+1919+impl<N> Column<N>
2020+where
2121+ N: Clone,
2222+{
2323+ pub(crate) fn matches(&self, n: &N) -> bool
2424+ where
2525+ N: Eq,
2626+ {
2727+ match self {
2828+ Column::Empty | Column::Blocked => false,
2929+ Column::Reserved(o) => n == o,
3030+ Column::Ancestor(o) => n == o,
3131+ Column::Parent(o) => n == o,
3232+ }
3333+ }
3434+3535+ fn variant(&self) -> usize {
3636+ match self {
3737+ Column::Empty => 0,
3838+ Column::Blocked => 1,
3939+ Column::Reserved(_) => 2,
4040+ Column::Ancestor(_) => 3,
4141+ Column::Parent(_) => 4,
4242+ }
4343+ }
4444+4545+ pub(crate) fn merge(&mut self, other: &Column<N>) {
4646+ if other.variant() > self.variant() {
4747+ *self = other.clone();
4848+ }
4949+ }
5050+5151+ fn reset(&mut self) {
5252+ match self {
5353+ Column::Blocked => *self = Column::Empty,
5454+ _ => {}
5555+ }
5656+ }
5757+}
5858+5959+pub(crate) trait ColumnsExt<N> {
6060+ fn find(&self, node: &N) -> Option<usize>;
6161+ fn find_empty(&self, index: usize) -> Option<usize>;
6262+ fn first_empty(&self) -> Option<usize>;
6363+ fn new_empty(&mut self) -> usize;
6464+ fn reset(&mut self);
6565+}
6666+6767+impl<N> ColumnsExt<N> for Vec<Column<N>>
6868+where
6969+ N: Clone + Eq,
7070+{
7171+ fn find(&self, node: &N) -> Option<usize> {
7272+ for (index, column) in self.iter().enumerate() {
7373+ if column.matches(node) {
7474+ return Some(index);
7575+ }
7676+ }
7777+ None
7878+ }
7979+8080+ fn find_empty(&self, index: usize) -> Option<usize> {
8181+ if self.get(index) == Some(&Column::Empty) {
8282+ return Some(index);
8383+ }
8484+ self.first_empty()
8585+ }
8686+8787+ fn first_empty(&self) -> Option<usize> {
8888+ for (i, column) in self.iter().enumerate() {
8989+ if *column == Column::Empty {
9090+ return Some(i);
9191+ }
9292+ }
9393+ None
9494+ }
9595+9696+ fn new_empty(&mut self) -> usize {
9797+ self.push(Column::Empty);
9898+ self.len() - 1
9999+ }
100100+101101+ fn reset(&mut self) {
102102+ for column in self.iter_mut() {
103103+ column.reset();
104104+ }
105105+ while let Some(Column::Empty) = self.last() {
106106+ self.pop();
107107+ }
108108+ }
109109+}
110110+```
111111+112112+box_drawing.rs
113113+```rs
114114+115115+/*
116116+ * Copyright (c) Meta Platforms, Inc. and affiliates.
117117+ *
118118+ * This source code is licensed under the MIT license found in the
119119+ * LICENSE file in the root directory of this source tree.
120120+ */
121121+122122+use std::marker::PhantomData;
123123+124124+use super::output::OutputRendererOptions;
125125+use super::render::Ancestor;
126126+use super::render::GraphRow;
127127+use super::render::LinkLine;
128128+use super::render::NodeLine;
129129+use super::render::PadLine;
130130+use super::render::Renderer;
131131+use crate::pad::pad_lines;
132132+133133+mod glyph {
134134+ pub(super) const SPACE: usize = 0;
135135+ pub(super) const HORIZONTAL: usize = 1;
136136+ pub(super) const PARENT: usize = 2;
137137+ pub(super) const ANCESTOR: usize = 3;
138138+ pub(super) const MERGE_LEFT: usize = 4;
139139+ pub(super) const MERGE_RIGHT: usize = 5;
140140+ pub(super) const MERGE_BOTH: usize = 6;
141141+ pub(super) const FORK_LEFT: usize = 7;
142142+ pub(super) const FORK_RIGHT: usize = 8;
143143+ pub(super) const FORK_BOTH: usize = 9;
144144+ pub(super) const JOIN_LEFT: usize = 10;
145145+ pub(super) const JOIN_RIGHT: usize = 11;
146146+ pub(super) const JOIN_BOTH: usize = 12;
147147+ pub(super) const TERMINATION: usize = 13;
148148+ pub(super) const COUNT: usize = 14;
149149+}
150150+151151+const SQUARE_GLYPHS: [&str; glyph::COUNT] = [
152152+ " ", "──", "│ ", "· ", "┘ ", "└─", "┴─", "┐ ", "┌─", "┬─", "┤ ", "├─", "┼─", "~ ",
153153+];
154154+155155+const CURVED_GLYPHS: [&str; glyph::COUNT] = [
156156+ " ", "──", "│ ", "╷ ", "╯ ", "╰─", "┴─", "╮ ", "╭─", "┬─", "┤ ", "├─", "┼─", "~ ",
157157+];
158158+159159+const DEC_GLYPHS: [&str; glyph::COUNT] = [
160160+ " ",
161161+ "\x1B(0qq\x1B(B",
162162+ "\x1B(0x \x1B(B",
163163+ "\x1B(0~ \x1B(B",
164164+ "\x1B(0j \x1B(B",
165165+ "\x1B(0mq\x1B(B",
166166+ "\x1B(0vq\x1B(B",
167167+ "\x1B(0k \x1B(B",
168168+ "\x1B(0lq\x1B(B",
169169+ "\x1B(0wq\x1B(B",
170170+ "\x1B(0u \x1B(B",
171171+ "\x1B(0tq\x1B(B",
172172+ "\x1B(0nq\x1B(B",
173173+ "~ ",
174174+];
175175+176176+impl PadLine {
177177+ fn to_glyph(&self) -> usize {
178178+ match *self {
179179+ PadLine::Parent => glyph::PARENT,
180180+ PadLine::Ancestor => glyph::ANCESTOR,
181181+ PadLine::Blank => glyph::SPACE,
182182+ }
183183+ }
184184+}
185185+186186+pub struct BoxDrawingRenderer<N, R>
187187+where
188188+ R: Renderer<N, Output = GraphRow<N>> + Sized,
189189+{
190190+ inner: R,
191191+ options: OutputRendererOptions,
192192+ extra_pad_line: Option<String>,
193193+ glyphs: &'static [&'static str; glyph::COUNT],
194194+ _phantom: PhantomData<N>,
195195+}
196196+197197+impl<N, R> BoxDrawingRenderer<N, R>
198198+where
199199+ R: Renderer<N, Output = GraphRow<N>> + Sized,
200200+{
201201+ pub(crate) fn new(inner: R, options: OutputRendererOptions) -> Self {
202202+ BoxDrawingRenderer {
203203+ inner,
204204+ options,
205205+ extra_pad_line: None,
206206+ glyphs: &CURVED_GLYPHS,
207207+ _phantom: PhantomData,
208208+ }
209209+ }
210210+211211+ pub fn with_square_glyphs(mut self) -> Self {
212212+ self.glyphs = &SQUARE_GLYPHS;
213213+ self
214214+ }
215215+216216+ pub fn with_dec_graphics_glyphs(mut self) -> Self {
217217+ self.glyphs = &DEC_GLYPHS;
218218+ self
219219+ }
220220+}
221221+222222+impl<N, R> Renderer<N> for BoxDrawingRenderer<N, R>
223223+where
224224+ N: Clone + Eq,
225225+ R: Renderer<N, Output = GraphRow<N>> + Sized,
226226+{
227227+ type Output = String;
228228+229229+ fn width(&self, node: Option<&N>, parents: Option<&Vec<Ancestor<N>>>) -> u64 {
230230+ self.inner
231231+ .width(node, parents)
232232+ .saturating_mul(2)
233233+ .saturating_add(1)
234234+ }
235235+236236+ fn reserve(&mut self, node: N) {
237237+ self.inner.reserve(node);
238238+ }
239239+240240+ fn next_row(
241241+ &mut self,
242242+ node: N,
243243+ parents: Vec<Ancestor<N>>,
244244+ glyph: String,
245245+ message: String,
246246+ ) -> String {
247247+ let glyphs = self.glyphs;
248248+ let line = self.inner.next_row(node, parents, glyph, message);
249249+ let mut out = String::new();
250250+ let mut message_lines = pad_lines(line.message.lines(), self.options.min_row_height);
251251+ let mut need_extra_pad_line = false;
252252+253253+ // Render the previous extra pad line
254254+ if let Some(extra_pad_line) = self.extra_pad_line.take() {
255255+ out.push_str(extra_pad_line.trim_end());
256256+ out.push('\n');
257257+ }
258258+259259+ // Render the nodeline
260260+ let mut node_line = String::new();
261261+ for entry in line.node_line.iter() {
262262+ match entry {
263263+ NodeLine::Node => {
264264+ node_line.push_str(&line.glyph);
265265+ node_line.push(' ');
266266+ }
267267+ NodeLine::Parent => node_line.push_str(glyphs[glyph::PARENT]),
268268+ NodeLine::Ancestor => node_line.push_str(glyphs[glyph::ANCESTOR]),
269269+ NodeLine::Blank => node_line.push_str(glyphs[glyph::SPACE]),
270270+ }
271271+ }
272272+ if let Some(msg) = message_lines.next() {
273273+ node_line.push(' ');
274274+ node_line.push_str(msg);
275275+ }
276276+ out.push_str(node_line.trim_end());
277277+ out.push('\n');
278278+279279+ // Render the link line
280280+ #[allow(clippy::if_same_then_else)]
281281+ if let Some(link_row) = line.link_line {
282282+ let mut link_line = String::new();
283283+ for cur in link_row.iter() {
284284+ if cur.intersects(LinkLine::HORIZONTAL) {
285285+ if cur.intersects(LinkLine::CHILD) {
286286+ link_line.push_str(glyphs[glyph::JOIN_BOTH]);
287287+ } else if cur.intersects(LinkLine::ANY_FORK)
288288+ && cur.intersects(LinkLine::ANY_MERGE)
289289+ {
290290+ link_line.push_str(glyphs[glyph::JOIN_BOTH]);
291291+ } else if cur.intersects(LinkLine::ANY_FORK)
292292+ && cur.intersects(LinkLine::VERT_PARENT)
293293+ && !line.merge
294294+ {
295295+ link_line.push_str(glyphs[glyph::JOIN_BOTH]);
296296+ } else if cur.intersects(LinkLine::ANY_FORK) {
297297+ link_line.push_str(glyphs[glyph::FORK_BOTH]);
298298+ } else if cur.intersects(LinkLine::ANY_MERGE) {
299299+ link_line.push_str(glyphs[glyph::MERGE_BOTH]);
300300+ } else {
301301+ link_line.push_str(glyphs[glyph::HORIZONTAL]);
302302+ }
303303+ } else if cur.intersects(LinkLine::VERT_PARENT) && !line.merge {
304304+ let left = cur.intersects(LinkLine::LEFT_MERGE | LinkLine::LEFT_FORK);
305305+ let right = cur.intersects(LinkLine::RIGHT_MERGE | LinkLine::RIGHT_FORK);
306306+ match (left, right) {
307307+ (true, true) => link_line.push_str(glyphs[glyph::JOIN_BOTH]),
308308+ (true, false) => link_line.push_str(glyphs[glyph::JOIN_LEFT]),
309309+ (false, true) => link_line.push_str(glyphs[glyph::JOIN_RIGHT]),
310310+ (false, false) => link_line.push_str(glyphs[glyph::PARENT]),
311311+ }
312312+ } else if cur.intersects(LinkLine::VERT_PARENT | LinkLine::VERT_ANCESTOR)
313313+ && !cur.intersects(LinkLine::LEFT_FORK | LinkLine::RIGHT_FORK)
314314+ {
315315+ let left = cur.intersects(LinkLine::LEFT_MERGE);
316316+ let right = cur.intersects(LinkLine::RIGHT_MERGE);
317317+ match (left, right) {
318318+ (true, true) => link_line.push_str(glyphs[glyph::JOIN_BOTH]),
319319+ (true, false) => link_line.push_str(glyphs[glyph::JOIN_LEFT]),
320320+ (false, true) => link_line.push_str(glyphs[glyph::JOIN_RIGHT]),
321321+ (false, false) => {
322322+ if cur.intersects(LinkLine::VERT_ANCESTOR) {
323323+ link_line.push_str(glyphs[glyph::ANCESTOR]);
324324+ } else {
325325+ link_line.push_str(glyphs[glyph::PARENT]);
326326+ }
327327+ }
328328+ }
329329+ } else if cur.intersects(LinkLine::LEFT_FORK)
330330+ && cur.intersects(LinkLine::LEFT_MERGE | LinkLine::CHILD)
331331+ {
332332+ link_line.push_str(glyphs[glyph::JOIN_LEFT]);
333333+ } else if cur.intersects(LinkLine::RIGHT_FORK)
334334+ && cur.intersects(LinkLine::RIGHT_MERGE | LinkLine::CHILD)
335335+ {
336336+ link_line.push_str(glyphs[glyph::JOIN_RIGHT]);
337337+ } else if cur.intersects(LinkLine::LEFT_MERGE)
338338+ && cur.intersects(LinkLine::RIGHT_MERGE)
339339+ {
340340+ link_line.push_str(glyphs[glyph::MERGE_BOTH]);
341341+ } else if cur.intersects(LinkLine::LEFT_FORK)
342342+ && cur.intersects(LinkLine::RIGHT_FORK)
343343+ {
344344+ link_line.push_str(glyphs[glyph::FORK_BOTH]);
345345+ } else if cur.intersects(LinkLine::LEFT_FORK) {
346346+ link_line.push_str(glyphs[glyph::FORK_LEFT]);
347347+ } else if cur.intersects(LinkLine::LEFT_MERGE) {
348348+ link_line.push_str(glyphs[glyph::MERGE_LEFT]);
349349+ } else if cur.intersects(LinkLine::RIGHT_FORK) {
350350+ link_line.push_str(glyphs[glyph::FORK_RIGHT]);
351351+ } else if cur.intersects(LinkLine::RIGHT_MERGE) {
352352+ link_line.push_str(glyphs[glyph::MERGE_RIGHT]);
353353+ } else {
354354+ link_line.push_str(glyphs[glyph::SPACE]);
355355+ }
356356+ }
357357+ if let Some(msg) = message_lines.next() {
358358+ link_line.push(' ');
359359+ link_line.push_str(msg);
360360+ }
361361+ out.push_str(link_line.trim_end());
362362+ out.push('\n');
363363+ }
364364+365365+ // Render the term line
366366+ if let Some(term_row) = line.term_line {
367367+ let term_strs = [glyphs[glyph::PARENT], glyphs[glyph::TERMINATION]];
368368+ for term_str in term_strs.iter() {
369369+ let mut term_line = String::new();
370370+ for (i, term) in term_row.iter().enumerate() {
371371+ if *term {
372372+ term_line.push_str(term_str);
373373+ } else {
374374+ term_line.push_str(glyphs[line.pad_lines[i].to_glyph()]);
375375+ }
376376+ }
377377+ if let Some(msg) = message_lines.next() {
378378+ term_line.push(' ');
379379+ term_line.push_str(msg);
380380+ }
381381+ out.push_str(term_line.trim_end());
382382+ out.push('\n');
383383+ }
384384+ need_extra_pad_line = true;
385385+ }
386386+387387+ let mut base_pad_line = String::new();
388388+ for entry in line.pad_lines.iter() {
389389+ base_pad_line.push_str(glyphs[entry.to_glyph()]);
390390+ }
391391+392392+ // Render any pad lines
393393+ for msg in message_lines {
394394+ let mut pad_line = base_pad_line.clone();
395395+ pad_line.push(' ');
396396+ pad_line.push_str(msg);
397397+ out.push_str(pad_line.trim_end());
398398+ out.push('\n');
399399+ need_extra_pad_line = false;
400400+ }
401401+402402+ if need_extra_pad_line {
403403+ self.extra_pad_line = Some(base_pad_line);
404404+ }
405405+406406+ out
407407+ }
408408+}
409409+410410+#[cfg(test)]
411411+mod tests {
412412+ use super::super::test_fixtures;
413413+ use super::super::test_fixtures::TestFixture;
414414+ use super::super::test_utils::render_string;
415415+ use super::super::test_utils::render_string_with_order;
416416+ use crate::GraphRowRenderer;
417417+418418+ fn render(fixture: &TestFixture) -> String {
419419+ let mut renderer = GraphRowRenderer::new().output().build_box_drawing();
420420+ render_string(fixture, &mut renderer)
421421+ }
422422+423423+ #[test]
424424+ fn basic() {
425425+ assert_eq!(
426426+ render(&test_fixtures::BASIC),
427427+ r#"
428428+ o C
429429+ │
430430+ o B
431431+ │
432432+ o A"#
433433+ );
434434+ }
435435+436436+ #[test]
437437+ fn branches_and_merges() {
438438+ assert_eq!(
439439+ render(&test_fixtures::BRANCHES_AND_MERGES),
440440+ r#"
441441+ o W
442442+ │
443443+ o V
444444+ ├─╮
445445+ │ o U
446446+ │ ├─╮
447447+ │ │ o T
448448+ │ │ │
449449+ │ o │ S
450450+ │ │
451451+ o │ R
452452+ │ │
453453+ o │ Q
454454+ ├─╮ │
455455+ │ o │ P
456456+ │ ├───╮
457457+ │ │ │ o O
458458+ │ │ │ │
459459+ │ │ │ o N
460460+ │ │ │ ├─╮
461461+ │ o │ │ │ M
462462+ │ │ │ │ │
463463+ │ o │ │ │ L
464464+ │ │ │ │ │
465465+ o │ │ │ │ K
466466+ ├───────╯
467467+ o │ │ │ J
468468+ │ │ │ │
469469+ o │ │ │ I
470470+ ├─╯ │ │
471471+ o │ │ H
472472+ │ │ │
473473+ o │ │ G
474474+ ├─────╮
475475+ │ │ o F
476476+ │ ├─╯
477477+ │ o E
478478+ │ │
479479+ o │ D
480480+ │ │
481481+ o │ C
482482+ ├───╯
483483+ o B
484484+ │
485485+ o A"#
486486+ );
487487+ }
488488+489489+ #[test]
490490+ fn octopus_branch_and_merge() {
491491+ assert_eq!(
492492+ render(&test_fixtures::OCTOPUS_BRANCH_AND_MERGE),
493493+ r#"
494494+ o J
495495+ ├─┬─╮
496496+ │ │ o I
497497+ │ │ │
498498+ │ o │ H
499499+ ╭─┼─┬─┬─╮
500500+ │ │ │ │ o G
501501+ │ │ │ │ │
502502+ │ │ │ o │ E
503503+ │ │ │ ├─╯
504504+ │ │ o │ D
505505+ │ │ ├─╮
506506+ │ o │ │ C
507507+ │ ├───╯
508508+ o │ │ F
509509+ ├─╯ │
510510+ o │ B
511511+ ├───╯
512512+ o A"#
513513+ );
514514+ }
515515+516516+ #[test]
517517+ fn reserved_column() {
518518+ assert_eq!(
519519+ render(&test_fixtures::RESERVED_COLUMN),
520520+ r#"
521521+ o Z
522522+ │
523523+ o Y
524524+ │
525525+ o X
526526+ ╭─╯
527527+ │ o W
528528+ ├─╯
529529+ o G
530530+ │
531531+ o F
532532+ ├─╮
533533+ │ o E
534534+ │ │
535535+ │ o D
536536+ │
537537+ o C
538538+ │
539539+ o B
540540+ │
541541+ o A"#
542542+ );
543543+ }
544544+545545+ #[test]
546546+ fn ancestors() {
547547+ assert_eq!(
548548+ render(&test_fixtures::ANCESTORS),
549549+ r#"
550550+ o Z
551551+ │
552552+ o Y
553553+ ╭─╯
554554+ o F
555555+ ╷
556556+ ╷ o X
557557+ ╭─╯
558558+ │ o W
559559+ ├─╯
560560+ o E
561561+ ╷
562562+ o D
563563+ ├─╮
564564+ │ o C
565565+ │ ╷
566566+ o ╷ B
567567+ ├─╯
568568+ o A"#
569569+ );
570570+ }
571571+572572+ #[test]
573573+ fn split_parents() {
574574+ assert_eq!(
575575+ render(&test_fixtures::SPLIT_PARENTS),
576576+ r#"
577577+ o E
578578+ ╭─┬─┬─┤
579579+ ╷ o │ ╷ D
580580+ ╭─┴─╮ ╷
581581+ │ o ╷ C
582582+ │ ├─╯
583583+ o │ B
584584+ ├───╯
585585+ o A"#
586586+ );
587587+ }
588588+589589+ #[test]
590590+ fn terminations() {
591591+ assert_eq!(
592592+ render(&test_fixtures::TERMINATIONS),
593593+ r#"
594594+ o K
595595+ │
596596+ │ o J
597597+ ├─╯
598598+ o I
599599+ ╭─┼─╮
600600+ │ │ │
601601+ │ ~ │
602602+ │ │
603603+ │ o H
604604+ │ │
605605+ o │ E
606606+ ├───╯
607607+ o D
608608+ │
609609+ ~
610610+611611+ o C
612612+ │
613613+ o B
614614+ │
615615+ ~"#
616616+ );
617617+ }
618618+619619+ #[test]
620620+ fn long_messages() {
621621+ assert_eq!(
622622+ render(&test_fixtures::LONG_MESSAGES),
623623+ r#"
624624+ o F
625625+ ├─┬─╮ very long message 1
626626+ │ │ │ very long message 2
627627+ │ │ ~ very long message 3
628628+ │ │
629629+ │ │ very long message 4
630630+ │ │ very long message 5
631631+ │ │ very long message 6
632632+ │ │
633633+ │ o E
634634+ │ │
635635+ │ o D
636636+ │ │
637637+ o │ C
638638+ ├─╯ long message 1
639639+ │ long message 2
640640+ │ long message 3
641641+ │
642642+ o B
643643+ │
644644+ o A
645645+ │ long message 1
646646+ ~ long message 2
647647+ long message 3"#
648648+ );
649649+ }
650650+651651+ #[test]
652652+ fn different_orders() {
653653+ let order = |order: &str| {
654654+ let order = order.matches(|_: char| true).collect::<Vec<_>>();
655655+ let mut renderer = GraphRowRenderer::new().output().build_box_drawing();
656656+ render_string_with_order(&test_fixtures::ORDERS1, &mut renderer, Some(&order))
657657+ };
658658+659659+ assert_eq!(
660660+ order("KJIHGFEDCBZA"),
661661+ r#"
662662+ o K
663663+ ├─╮
664664+ │ o J
665665+ │ ├─╮
666666+ │ │ o I
667667+ │ │ ├─╮
668668+ │ │ │ o H
669669+ │ │ │ ├─╮
670670+ │ │ │ │ o G
671671+ │ │ │ │ ├─╮
672672+ o │ │ │ │ │ F
673673+ │ │ │ │ │ │
674674+ │ o │ │ │ │ E
675675+ ├─╯ │ │ │ │
676676+ │ o │ │ │ D
677677+ ├───╯ │ │ │
678678+ │ o │ │ C
679679+ ├─────╯ │ │
680680+ │ o │ B
681681+ ├───────╯ │
682682+ │ o Z
683683+ │
684684+ o A"#
685685+ );
686686+687687+ assert_eq!(
688688+ order("KJIHGZBCDEFA"),
689689+ r#"
690690+ o K
691691+ ├─╮
692692+ │ o J
693693+ │ ├─╮
694694+ │ │ o I
695695+ │ │ ├─╮
696696+ │ │ │ o H
697697+ │ │ │ ├─╮
698698+ │ │ │ │ o G
699699+ │ │ │ │ ├─╮
700700+ │ │ │ │ │ o Z
701701+ │ │ │ │ │
702702+ │ │ │ │ o B
703703+ │ │ │ │ │
704704+ │ │ │ o │ C
705705+ │ │ │ ├─╯
706706+ │ │ o │ D
707707+ │ │ ├─╯
708708+ │ o │ E
709709+ │ ├─╯
710710+ o │ F
711711+ ├─╯
712712+ o A"#
713713+ );
714714+715715+ // Keeping the p1 branch the longest path (KFEDCBA) is a reasonable
716716+ // optimization for a cleaner graph (less columns, more text space).
717717+ assert_eq!(
718718+ render(&test_fixtures::ORDERS2),
719719+ r#"
720720+ o K
721721+ ├─╮
722722+ │ o J
723723+ │ │
724724+ o │ F
725725+ ├───╮
726726+ │ │ o I
727727+ │ ├─╯
728728+ o │ E
729729+ ├───╮
730730+ │ │ o H
731731+ │ ├─╯
732732+ o │ D
733733+ ├───╮
734734+ │ │ o G
735735+ │ ├─╯
736736+ o │ C
737737+ ├───╮
738738+ │ │ o Z
739739+ │ │
740740+ o │ B
741741+ ├─╯
742742+ o A"#
743743+ );
744744+745745+ // Try to use the ORDERS2 order. However, the parent ordering in the
746746+ // graph is different, which makes the rendering different.
747747+ //
748748+ // Note: it's KJFIEHDGCZBA in the ORDERS2 graph. To map it to ORDERS1,
749749+ // follow:
750750+ //
751751+ // ORDERS1: KFJEIDHCGBZA
752752+ // ORDERS2: KJFIEHDGCBZA
753753+ //
754754+ // And we get KFJEIDHCGZBA.
755755+ assert_eq!(
756756+ order("KFJEIDHCGZBA"),
757757+ r#"
758758+ o K
759759+ ├─╮
760760+ o │ F
761761+ │ │
762762+ │ o J
763763+ │ ├─╮
764764+ │ o │ E
765765+ ├─╯ │
766766+ │ o I
767767+ │ ╭─┤
768768+ │ │ o D
769769+ ├───╯
770770+ │ o H
771771+ │ ├─╮
772772+ │ o │ C
773773+ ├─╯ │
774774+ │ o G
775775+ │ ╭─┤
776776+ │ o │ Z
777777+ │ │
778778+ │ o B
779779+ ├───╯
780780+ o A"#
781781+ );
782782+ }
783783+}
784784+```
785785+786786+renderer.rs
787787+```rs
788788+789789+/*
790790+ * Copyright (c) Meta Platforms, Inc. and affiliates.
791791+ *
792792+ * This source code is licensed under the MIT license found in the
793793+ * LICENSE file in the root directory of this source tree.
794794+ */
795795+796796+use std::collections::BTreeMap;
797797+use std::ops::Range;
798798+799799+use bitflags::bitflags;
800800+#[cfg(feature = "serialize")]
801801+use serde::Serialize;
802802+803803+use super::column::Column;
804804+use super::column::ColumnsExt;
805805+use super::output::OutputRendererBuilder;
806806+807807+pub trait Renderer<N> {
808808+ type Output;
809809+810810+ // Returns the width of the graph line, possibly including another node.
811811+ fn width(&self, new_node: Option<&N>, new_parents: Option<&Vec<Ancestor<N>>>) -> u64;
812812+813813+ // Reserve a column for the given node.
814814+ fn reserve(&mut self, node: N);
815815+816816+ // Render the next row.
817817+ fn next_row(
818818+ &mut self,
819819+ node: N,
820820+ parents: Vec<Ancestor<N>>,
821821+ glyph: String,
822822+ message: String,
823823+ ) -> Self::Output;
824824+}
825825+826826+/// Renderer for a DAG.
827827+///
828828+/// Converts a sequence of DAG node descriptions into rendered graph rows.
829829+pub struct GraphRowRenderer<N> {
830830+ columns: Vec<Column<N>>,
831831+}
832832+833833+/// Ancestor type indication for an ancestor or parent node.
834834+pub enum Ancestor<N> {
835835+ /// The node is an eventual ancestor.
836836+ Ancestor(N),
837837+838838+ /// The node is an immediate parent.
839839+ Parent(N),
840840+841841+ /// The node is an anonymous ancestor.
842842+ Anonymous,
843843+}
844844+845845+impl<N> Ancestor<N> {
846846+ fn to_column(&self) -> Column<N>
847847+ where
848848+ N: Clone,
849849+ {
850850+ match self {
851851+ Ancestor::Ancestor(n) => Column::Ancestor(n.clone()),
852852+ Ancestor::Parent(n) => Column::Parent(n.clone()),
853853+ Ancestor::Anonymous => Column::Blocked,
854854+ }
855855+ }
856856+857857+ fn id(&self) -> Option<&N> {
858858+ match self {
859859+ Ancestor::Ancestor(n) => Some(n),
860860+ Ancestor::Parent(n) => Some(n),
861861+ Ancestor::Anonymous => None,
862862+ }
863863+ }
864864+865865+ fn is_direct(&self) -> bool {
866866+ match self {
867867+ Ancestor::Ancestor(_) => false,
868868+ Ancestor::Parent(_) => true,
869869+ Ancestor::Anonymous => true,
870870+ }
871871+ }
872872+873873+ fn to_link_line(&self, direct: LinkLine, indirect: LinkLine) -> LinkLine {
874874+ if self.is_direct() { direct } else { indirect }
875875+ }
876876+}
877877+878878+struct AncestorColumnBounds {
879879+ target: usize,
880880+ min_ancestor: usize,
881881+ min_parent: usize,
882882+ max_parent: usize,
883883+ max_ancestor: usize,
884884+}
885885+886886+impl AncestorColumnBounds {
887887+ fn new<N>(columns: &BTreeMap<usize, &Ancestor<N>>, target: usize) -> Option<Self> {
888888+ if columns.is_empty() {
889889+ return None;
890890+ }
891891+ let min_ancestor = columns
892892+ .iter()
893893+ .next()
894894+ .map_or(target, |(index, _)| *index)
895895+ .min(target);
896896+ let max_ancestor = columns
897897+ .iter()
898898+ .next_back()
899899+ .map_or(target, |(index, _)| *index)
900900+ .max(target);
901901+ let min_parent = columns
902902+ .iter()
903903+ .find(|(_, ancestor)| ancestor.is_direct())
904904+ .map_or(target, |(index, _)| *index)
905905+ .min(target);
906906+ let max_parent = columns
907907+ .iter()
908908+ .rev()
909909+ .find(|(_, ancestor)| ancestor.is_direct())
910910+ .map_or(target, |(index, _)| *index)
911911+ .max(target);
912912+ Some(Self {
913913+ target,
914914+ min_ancestor,
915915+ min_parent,
916916+ max_parent,
917917+ max_ancestor,
918918+ })
919919+ }
920920+921921+ fn range(&self) -> Range<usize> {
922922+ if self.min_ancestor < self.max_ancestor {
923923+ self.min_ancestor + 1..self.max_ancestor
924924+ } else {
925925+ Default::default()
926926+ }
927927+ }
928928+929929+ fn horizontal_line(&self, index: usize) -> LinkLine {
930930+ if index == self.target {
931931+ LinkLine::empty()
932932+ } else if index > self.min_parent && index < self.max_parent {
933933+ LinkLine::HORIZ_PARENT
934934+ } else if index > self.min_ancestor && index < self.max_ancestor {
935935+ LinkLine::HORIZ_ANCESTOR
936936+ } else {
937937+ LinkLine::empty()
938938+ }
939939+ }
940940+}
941941+942942+impl<N> Column<N> {
943943+ fn to_node_line(&self) -> NodeLine {
944944+ match self {
945945+ Column::Ancestor(_) => NodeLine::Ancestor,
946946+ Column::Parent(_) => NodeLine::Parent,
947947+ _ => NodeLine::Blank,
948948+ }
949949+ }
950950+951951+ fn to_link_line(&self) -> LinkLine {
952952+ match self {
953953+ Column::Ancestor(_) => LinkLine::VERT_ANCESTOR,
954954+ Column::Parent(_) => LinkLine::VERT_PARENT,
955955+ _ => LinkLine::empty(),
956956+ }
957957+ }
958958+959959+ fn to_pad_line(&self) -> PadLine {
960960+ match self {
961961+ Column::Ancestor(_) => PadLine::Ancestor,
962962+ Column::Parent(_) => PadLine::Parent,
963963+ _ => PadLine::Blank,
964964+ }
965965+ }
966966+}
967967+968968+/// A column in the node row.
969969+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
970970+#[cfg_attr(feature = "serialize", derive(Serialize))]
971971+pub enum NodeLine {
972972+ /// Blank.
973973+ Blank,
974974+975975+ /// Vertical line indicating an ancestor.
976976+ Ancestor,
977977+978978+ /// Vertical line indicating a parent.
979979+ Parent,
980980+981981+ /// The node for this row.
982982+ Node,
983983+}
984984+985985+/// A column in a padding row.
986986+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
987987+#[cfg_attr(feature = "serialize", derive(Serialize))]
988988+pub enum PadLine {
989989+ /// Blank.
990990+ Blank,
991991+992992+ /// Vertical line indicating an ancestor.
993993+ Ancestor,
994994+995995+ /// Vertical line indicating a parent.
996996+ Parent,
997997+}
998998+999999+bitflags! {
10001000+ /// A column in a linking row.
10011001+ #[cfg_attr(feature = "serialize", derive(Serialize))]
10021002+ #[derive(Default, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)]
10031003+ pub struct LinkLine: u16 {
10041004+ /// This cell contains a horizontal line that connects to a parent.
10051005+ const HORIZ_PARENT = 0b0_0000_0000_0001;
10061006+10071007+ /// This cell contains a horizontal line that connects to an ancestor.
10081008+ const HORIZ_ANCESTOR = 0b0_0000_0000_0010;
10091009+10101010+ /// The descendent of this cell is connected to the parent.
10111011+ const VERT_PARENT = 0b0_0000_0000_0100;
10121012+10131013+ /// The descendent of this cell is connected to an ancestor.
10141014+ const VERT_ANCESTOR = 0b0_0000_0000_1000;
10151015+10161016+ /// The parent of this cell is linked in this link row and the child
10171017+ /// is to the left.
10181018+ const LEFT_FORK_PARENT = 0b0_0000_0001_0000;
10191019+10201020+ /// The ancestor of this cell is linked in this link row and the child
10211021+ /// is to the left.
10221022+ const LEFT_FORK_ANCESTOR = 0b0_0000_0010_0000;
10231023+10241024+ /// The parent of this cell is linked in this link row and the child
10251025+ /// is to the right.
10261026+ const RIGHT_FORK_PARENT = 0b0_0000_0100_0000;
10271027+10281028+ /// The ancestor of this cell is linked in this link row and the child
10291029+ /// is to the right.
10301030+ const RIGHT_FORK_ANCESTOR = 0b0_0000_1000_0000;
10311031+10321032+ /// The child of this cell is linked to parents on the left.
10331033+ const LEFT_MERGE_PARENT = 0b0_0001_0000_0000;
10341034+10351035+ /// The child of this cell is linked to ancestors on the left.
10361036+ const LEFT_MERGE_ANCESTOR = 0b0_0010_0000_0000;
10371037+10381038+ /// The child of this cell is linked to parents on the right.
10391039+ const RIGHT_MERGE_PARENT = 0b0_0100_0000_0000;
10401040+10411041+ /// The child of this cell is linked to ancestors on the right.
10421042+ const RIGHT_MERGE_ANCESTOR = 0b0_1000_0000_0000;
10431043+10441044+ /// The target node of this link line is the child of this column.
10451045+ /// This disambiguates between the node that is connected in this link
10461046+ /// line, and other nodes that are also connected vertically.
10471047+ const CHILD = 0b1_0000_0000_0000;
10481048+10491049+ const HORIZONTAL = Self::HORIZ_PARENT.bits() | Self::HORIZ_ANCESTOR.bits();
10501050+ const VERTICAL = Self::VERT_PARENT.bits() | Self::VERT_ANCESTOR.bits();
10511051+ const LEFT_FORK = Self::LEFT_FORK_PARENT.bits() | Self::LEFT_FORK_ANCESTOR.bits();
10521052+ const RIGHT_FORK = Self::RIGHT_FORK_PARENT.bits() | Self::RIGHT_FORK_ANCESTOR.bits();
10531053+ const LEFT_MERGE = Self::LEFT_MERGE_PARENT.bits() | Self::LEFT_MERGE_ANCESTOR.bits();
10541054+ const RIGHT_MERGE = Self::RIGHT_MERGE_PARENT.bits() | Self::RIGHT_MERGE_ANCESTOR.bits();
10551055+ const ANY_MERGE = Self::LEFT_MERGE.bits() | Self::RIGHT_MERGE.bits();
10561056+ const ANY_FORK = Self::LEFT_FORK.bits() | Self::RIGHT_FORK.bits();
10571057+ const ANY_FORK_OR_MERGE = Self::ANY_MERGE.bits() | Self::ANY_FORK.bits();
10581058+ }
10591059+}
10601060+10611061+/// An output graph row.
10621062+#[derive(Debug)]
10631063+#[cfg_attr(feature = "serialize", derive(Serialize))]
10641064+pub struct GraphRow<N> {
10651065+ /// The name of the node for this row.
10661066+ pub node: N,
10671067+10681068+ /// The glyph for this node.
10691069+ pub glyph: String,
10701070+10711071+ /// The message for this row.
10721072+ pub message: String,
10731073+10741074+ /// True if this row is for a merge commit.
10751075+ pub merge: bool,
10761076+10771077+ /// The node columns for this row.
10781078+ pub node_line: Vec<NodeLine>,
10791079+10801080+ /// The link columns for this row, if a link row is necessary.
10811081+ pub link_line: Option<Vec<LinkLine>>,
10821082+10831083+ /// The location of any terminators, if necessary. Other columns should be
10841084+ /// filled in with pad lines.
10851085+ pub term_line: Option<Vec<bool>>,
10861086+10871087+ /// The pad columns for this row.
10881088+ pub pad_lines: Vec<PadLine>,
10891089+}
10901090+10911091+impl<N> GraphRowRenderer<N>
10921092+where
10931093+ N: Clone + Eq + std::fmt::Debug,
10941094+{
10951095+ /// Create a new renderer.
10961096+ pub fn new() -> Self {
10971097+ GraphRowRenderer {
10981098+ columns: Vec::new(),
10991099+ }
11001100+ }
11011101+11021102+ /// Build an output renderer from this renderer.
11031103+ pub fn output(self) -> OutputRendererBuilder<N, Self> {
11041104+ OutputRendererBuilder::new(self)
11051105+ }
11061106+}
11071107+11081108+impl<N> Renderer<N> for GraphRowRenderer<N>
11091109+where
11101110+ N: Clone + Eq + std::fmt::Debug,
11111111+{
11121112+ type Output = GraphRow<N>;
11131113+11141114+ fn width(&self, node: Option<&N>, parents: Option<&Vec<Ancestor<N>>>) -> u64 {
11151115+ let mut width = self.columns.len();
11161116+ let mut empty_columns = self
11171117+ .columns
11181118+ .iter()
11191119+ .filter(|&column| column == &Column::Empty)
11201120+ .count();
11211121+ if let Some(node) = node {
11221122+ // If the node is not already allocated, and there is no
11231123+ // space for the node, then adding the new node would create
11241124+ // a new column.
11251125+ if self.columns.find(node).is_none() {
11261126+ if empty_columns == 0 {
11271127+ width += 1;
11281128+ } else {
11291129+ empty_columns = empty_columns.saturating_sub(1);
11301130+ }
11311131+ }
11321132+ }
11331133+ if let Some(parents) = parents {
11341134+ // Non-allocated parents will also need a new column (except
11351135+ // for one, which can take the place of the node, and any that could be allocated to
11361136+ // empty columns).
11371137+ let unallocated_parents = parents
11381138+ .iter()
11391139+ .filter(|parent| {
11401140+ parent
11411141+ .id()
11421142+ .is_none_or(|parent| self.columns.find(parent).is_none())
11431143+ })
11441144+ .count()
11451145+ .saturating_sub(empty_columns);
11461146+ width += unallocated_parents.saturating_sub(1);
11471147+ }
11481148+ width as u64
11491149+ }
11501150+11511151+ fn reserve(&mut self, node: N) {
11521152+ if self.columns.find(&node).is_none() {
11531153+ if let Some(index) = self.columns.first_empty() {
11541154+ self.columns[index] = Column::Reserved(node);
11551155+ } else {
11561156+ self.columns.push(Column::Reserved(node));
11571157+ }
11581158+ }
11591159+ }
11601160+11611161+ fn next_row(
11621162+ &mut self,
11631163+ node: N,
11641164+ parents: Vec<Ancestor<N>>,
11651165+ glyph: String,
11661166+ message: String,
11671167+ ) -> GraphRow<N> {
11681168+ // Find a column for this node.
11691169+ let column = self.columns.find(&node).unwrap_or_else(|| {
11701170+ self.columns
11711171+ .first_empty()
11721172+ .unwrap_or_else(|| self.columns.new_empty())
11731173+ });
11741174+ self.columns[column] = Column::Empty;
11751175+11761176+ // This row is for a merge if there are multiple parents.
11771177+ let merge = parents.len() > 1;
11781178+11791179+ // Build the initial node line.
11801180+ let mut node_line: Vec<_> = self.columns.iter().map(|c| c.to_node_line()).collect();
11811181+ node_line[column] = NodeLine::Node;
11821182+11831183+ // Build the initial link line.
11841184+ let mut link_line: Vec<_> = self.columns.iter().map(|c| c.to_link_line()).collect();
11851185+ let mut need_link_line = false;
11861186+11871187+ // Build the initial term line.
11881188+ let mut term_line: Vec<_> = self.columns.iter().map(|_| false).collect();
11891189+ let mut need_term_line = false;
11901190+11911191+ // Build the initial pad line.
11921192+ let mut pad_lines: Vec<_> = self.columns.iter().map(|c| c.to_pad_line()).collect();
11931193+11941194+ // Assign each parent to a column.
11951195+ let mut parent_columns = BTreeMap::new();
11961196+ for p in parents.iter() {
11971197+ // Check if the parent already has a column.
11981198+ if let Some(parent_id) = p.id() {
11991199+ if let Some(index) = self.columns.find(parent_id) {
12001200+ self.columns[index].merge(&p.to_column());
12011201+ parent_columns.insert(index, p);
12021202+ continue;
12031203+ }
12041204+ }
12051205+ // Assign the parent to an empty column, preferring the column
12061206+ // the current node is going in, to maintain linearity.
12071207+ if let Some(index) = self.columns.find_empty(column) {
12081208+ self.columns[index].merge(&p.to_column());
12091209+ parent_columns.insert(index, p);
12101210+ continue;
12111211+ }
12121212+ // There are no empty columns left. Make a new column.
12131213+ parent_columns.insert(self.columns.len(), p);
12141214+ node_line.push(NodeLine::Blank);
12151215+ pad_lines.push(PadLine::Blank);
12161216+ link_line.push(LinkLine::default());
12171217+ term_line.push(false);
12181218+ self.columns.push(p.to_column());
12191219+ }
12201220+12211221+ // Mark parent columns with anonymous parents as terminating.
12221222+ for (i, p) in parent_columns.iter() {
12231223+ if p.id().is_none() {
12241224+ term_line[*i] = true;
12251225+ need_term_line = true;
12261226+ }
12271227+ }
12281228+12291229+ // Check if we can move the parent to the current column.
12301230+ if parents.len() == 1 {
12311231+ if let Some((&parent_column, _)) = parent_columns.iter().next() {
12321232+ if parent_column > column {
12331233+ // This node has a single parent which was already
12341234+ // assigned to a column to the right of this one.
12351235+ // Move the parent to this column.
12361236+ self.columns.swap(column, parent_column);
12371237+ let parent = parent_columns
12381238+ .remove(&parent_column)
12391239+ .expect("parent should exist");
12401240+ parent_columns.insert(column, parent);
12411241+12421242+ // Generate a line from this column to the old
12431243+ // parent column. We need to continue with the style
12441244+ // that was being used for the parent column.
12451245+ let was_direct = link_line[parent_column].contains(LinkLine::VERT_PARENT);
12461246+ link_line[column] |= if was_direct {
12471247+ LinkLine::RIGHT_FORK_PARENT
12481248+ } else {
12491249+ LinkLine::RIGHT_FORK_ANCESTOR
12501250+ };
12511251+ #[allow(clippy::needless_range_loop)]
12521252+ for i in column + 1..parent_column {
12531253+ link_line[i] |= if was_direct {
12541254+ LinkLine::HORIZ_PARENT
12551255+ } else {
12561256+ LinkLine::HORIZ_ANCESTOR
12571257+ };
12581258+ }
12591259+ link_line[parent_column] = if was_direct {
12601260+ LinkLine::LEFT_MERGE_PARENT
12611261+ } else {
12621262+ LinkLine::LEFT_MERGE_ANCESTOR
12631263+ };
12641264+ need_link_line = true;
12651265+ // The pad line for the old parent column is now blank.
12661266+ pad_lines[parent_column] = PadLine::Blank;
12671267+ }
12681268+ }
12691269+ }
12701270+12711271+ // Connect the node column to all the parent columns.
12721272+ if let Some(bounds) = AncestorColumnBounds::new(&parent_columns, column) {
12731273+ // If the parents extend beyond the columns adjacent to the node, draw a horizontal
12741274+ // ancestor line between the two outermost ancestors.
12751275+ for i in bounds.range() {
12761276+ link_line[i] |= bounds.horizontal_line(i);
12771277+ need_link_line = true;
12781278+ }
12791279+12801280+ // If there is a parent or ancestor to the right of the node
12811281+ // column, the node merges from the right.
12821282+ if bounds.max_parent > column {
12831283+ link_line[column] |= LinkLine::RIGHT_MERGE_PARENT;
12841284+ need_link_line = true;
12851285+ } else if bounds.max_ancestor > column {
12861286+ link_line[column] |= LinkLine::RIGHT_MERGE_ANCESTOR;
12871287+ need_link_line = true;
12881288+ }
12891289+12901290+ // If there is a parent or ancestor to the left of the node column, the node merges from the left.
12911291+ if bounds.min_parent < column {
12921292+ link_line[column] |= LinkLine::LEFT_MERGE_PARENT;
12931293+ need_link_line = true;
12941294+ } else if bounds.min_ancestor < column {
12951295+ link_line[column] |= LinkLine::LEFT_MERGE_ANCESTOR;
12961296+ need_link_line = true;
12971297+ }
12981298+12991299+ // Each parent or ancestor forks towards the node column.
13001300+ #[allow(clippy::comparison_chain)]
13011301+ for (&i, p) in parent_columns.iter() {
13021302+ pad_lines[i] = self.columns[i].to_pad_line();
13031303+ if i < column {
13041304+ link_line[i] |=
13051305+ p.to_link_line(LinkLine::RIGHT_FORK_PARENT, LinkLine::RIGHT_FORK_ANCESTOR);
13061306+ } else if i == column {
13071307+ link_line[i] |= LinkLine::CHILD
13081308+ | p.to_link_line(LinkLine::VERT_PARENT, LinkLine::VERT_ANCESTOR);
13091309+ } else {
13101310+ link_line[i] |=
13111311+ p.to_link_line(LinkLine::LEFT_FORK_PARENT, LinkLine::LEFT_FORK_ANCESTOR);
13121312+ }
13131313+ }
13141314+ }
13151315+13161316+ // Now that we have assigned all the columns, reset their state.
13171317+ self.columns.reset();
13181318+13191319+ // Filter out the link line or term line if they are not needed.
13201320+ let link_line = Some(link_line).filter(|_| need_link_line);
13211321+ let term_line = Some(term_line).filter(|_| need_term_line);
13221322+13231323+ GraphRow {
13241324+ node,
13251325+ glyph,
13261326+ message,
13271327+ merge,
13281328+ node_line,
13291329+ link_line,
13301330+ term_line,
13311331+ pad_lines,
13321332+ }
13331333+ }
13341334+}
13351335+```
+85-16
jj_tui/bin/graph_view.ml
···1616 (* Import graph commands *)
1717 module GraphCommands = Graph_commands.Make (Vars)
18181919+ (** Render commit content for a node - shows change_id, author, timestamp, description, bookmarks *)
2020+ let render_commit_content (node : Render_jj_graph.node) : Notty.image =
2121+ let open Notty in
2222+ let open Notty.A in
2323+ let styled_text attr text = I.string attr text in
2424+ let change_id_short =
2525+ String.sub node.change_id 0 (min 8 (String.length node.change_id))
2626+ in
2727+ let author_name =
2828+ match String.split_on_char '@' node.author_email with
2929+ | name :: _ ->
3030+ name
3131+ | [] ->
3232+ node.author_email
3333+ in
3434+ let description_line =
3535+ match String.split_on_char '\n' node.description with
3636+ | first :: _ when String.trim first <> "" ->
3737+ String.trim first
3838+ | _ ->
3939+ "(no description set)"
4040+ in
4141+ let parts = ref [] in
4242+ let change_id_attr =
4343+ if node.is_preview
4444+ then fg lightblack ++ st dim
4545+ else if node.working_copy
4646+ then fg lightcyan ++ st bold
4747+ else if node.immutable
4848+ then fg lightmagenta
4949+ else if node.empty
5050+ then fg yellow
5151+ else fg cyan
5252+ in
5353+ parts := styled_text change_id_attr change_id_short :: !parts;
5454+ parts := styled_text (fg white ++ st dim) (" " ^ author_name) :: !parts;
5555+ parts := styled_text (fg white ++ st dim) (" " ^ node.author_timestamp) :: !parts;
5656+ if List.length node.bookmarks > 0
5757+ then (
5858+ let bookmarks_str = " (" ^ String.concat ", " node.bookmarks ^ ")" in
5959+ parts := styled_text (fg green ++ st bold) bookmarks_str :: !parts);
6060+ let desc_attr =
6161+ if node.is_preview || node.empty
6262+ then fg white ++ st dim
6363+ else if node.wip
6464+ then fg lightyellow
6565+ else fg white
6666+ in
6767+ parts := styled_text desc_attr (" " ^ description_line) :: !parts;
6868+ !parts |> List.rev |> I.hcat
6969+ ;;
7070+7171+ (** Render a graph row by combining graph prefix with content *)
7272+ let render_graph_row
7373+ (row : Render_jj_graph.graph_row_output)
7474+ ~(render_content : Render_jj_graph.node -> Notty.image) : Notty.image
7575+ =
7676+ let open Notty in
7777+ let graph_img = I.string A.empty row.graph_chars in
7878+ match row.row_type with
7979+ | NodeRow ->
8080+ let content_img = render_content row.node in
8181+ I.hcat [ graph_img; content_img ]
8282+ | LinkRow | PadRow | TermRow ->
8383+ graph_img
8484+ ;;
8585+1986 let bookmark_select_prompt get_bookmark_list name func =
2087 Selection_prompt
2188 ( name
···59126 |> Option.value ~default:(Ui.empty |> Lwd.pure)
60127 in
61128 let items =
6262- let$ graph, rev_ids =
129129+ let$ rendered_rows, rev_ids =
63130 (*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*)
64131 Vars.ui_state.trigger_update
65132 |> Lwd.get
66133 |> Lwd.map2 (Lwd.get Vars.ui_state.revset) ~f:(fun revset _ ->
67134 try
68135 let max_commits = (Vars.config |> Lwd.peek).max_commits in
6969- let res = graph_and_revs ?revset max_commits () in
136136+ let nodes, rev_ids = get_graph_nodes ?revset max_commits in
137137+ let state =
138138+ Render_jj_graph.{ depth = 0; columns = [||]; pending_joins = [] }
139139+ in
140140+ let rendered_rows = Render_jj_graph.render_nodes_structured state nodes in
70141 error_var $= None;
7171- res
142142+ rendered_rows, rev_ids
72143 with
73144 | Jj_process.JJError (cmd, error) ->
74145 (*If we have an error generating the graph,likely because the revset is wrong,just show the errror*)
75146 error_var $= Some (error |> Jj_tui.AnsiReverse.colored_string |> Ui.atom);
7676- [||], [||])
147147+ [], [||])
77148 in
78149 (*We will make two arrays, one with both selectable and filler and one with only selectable*)
79150 let selectable_idx = ref 0 in
8080- let selectable_items = Array.make (Array.length graph) (Obj.magic ()) in
151151+ let selectable_items = Array.make (Array.length rev_ids) (Obj.magic ()) in
81152 let items =
8282- graph
8383- |> Array.map (fun x ->
8484- match x with
8585- | `Selectable x ->
153153+ rendered_rows
154154+ |> List.map (fun (row : Render_jj_graph.graph_row_output) ->
155155+ match row.row_type with
156156+ | NodeRow ->
86157 let ui =
87158 W.Lists.selectable_item
8888- (x
8989- (* TODO This won't work if we are on a branch, because that puts the @ further out*)
9090- |> Jj_tui.AnsiReverse.colored_string
9191- |> Ui.atom)
159159+ (render_graph_row row ~render_content:render_commit_content |> Ui.atom)
92160 in
93161 let id = rev_ids.(!selectable_idx) in
94162 let data =
···103171 Array.set selectable_items !selectable_idx data;
104172 selectable_idx := !selectable_idx + 1;
105173 W.Lists.(Selectable data)
106106- | `Filler x ->
107107- W.Lists.(
108108- Filler (x |> Jj_tui.AnsiReverse.colored_string |> Ui.atom |> Lwd.pure)))
174174+ | LinkRow | PadRow | TermRow ->
175175+ let graph_img = I.string A.empty row.graph_chars in
176176+ W.Lists.(Filler (graph_img |> Ui.atom |> Lwd.pure)))
177177+ |> Array.of_list
109178 in
110179 items
111180 in
···1414# - Output is JSONL (one JSON object per line).
1515# - `wip` is a heuristic derived from the description's first line starting with "wip:".
16161717-REVSET="${1:-all()}"
1717+REVSET=""
1818OUTFILE="${2:-test/jj_log.json}"
19192020echo "Updating $OUTFILE with revset: $REVSET" >&2
···4141JJTEMPLATE
4242)
43434444-if [[ "${VERBOSE:-0}" == "1" ]]; then
4545- echo "TEMPLATE: $TEMPLATE" >&2
4646-fi
47444845mkdir -p "$(dirname "$OUTFILE")"
4946# We want a stable "top-to-bottom" order like `jj log`, but without graph text.
5047# Write to a temp file first so parse errors don't clobber the existing fixture.
5148tmp_out="${OUTFILE}.tmp"
5252-jj log --no-graph -r "$REVSET" -T "$TEMPLATE" > "$tmp_out"
4949+jj log -T "$TEMPLATE" > "$tmp_out"
5350mv "$tmp_out" "$OUTFILE"
54515552echo "Wrote $OUTFILE" >&2