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

Configure Feed

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

hook in graph renderer

+1272 -484
+119
CHANGES_SUMMARY.md
··· 1 + # Changes to Match Original JJ Rendering Format 2 + 3 + ## Goal 4 + Match the original `jj log` output format which displays commit information on two lines: 5 + 6 + **Original jj format:** 7 + ``` 8 + @ ztooztwk eli.jambu@gmail.com 2026-01-15 14:05:59 235795c5 9 + │ (empty) (no description set) 10 + ``` 11 + 12 + ## Changes Made 13 + 14 + ### 1. Updated `render_commit_content` in `graph_view.ml` (lines 19-79) 15 + 16 + **Previous format (single line):** 17 + ``` 18 + change_id author_name timestamp (bookmarks) description 19 + ``` 20 + 21 + **New format (two lines):** 22 + ``` 23 + Line 1: change_id email timestamp [bookmarks] commit_id_short 24 + Line 2: (empty) description 25 + ``` 26 + 27 + **Key changes:** 28 + - Show **full email** instead of extracting name before `@` 29 + - Add **commit_id_short** (8 characters) at end of line 1 30 + - Move **description** to line 2 31 + - Add **(empty)** prefix when `node.empty` is true 32 + - Remove parentheses around bookmarks, show as space-separated list 33 + - Use `I.vcat` to create two-line image 34 + 35 + ### 2. Updated `render_graph_row` in `graph_view.ml` (lines 81-109) 36 + 37 + **Problem:** When content is multi-line, the graph character only appears on the first line with `I.hcat [ graph_img; content_img ]`. 38 + 39 + **Solution:** Detect multi-line content and manually create graph continuation for subsequent lines: 40 + 41 + ```ocaml 42 + if content_height > 1 then 43 + (* Replace node glyphs (○, @, ◌, ◆) with vertical bar │ *) 44 + let graph_continuation = replace_node_glyphs_with_bar row.graph_chars in 45 + (* Create each line with appropriate graph prefix *) 46 + let lines = List.init content_height (fun i -> 47 + let line_img = I.vcrop i 1 content_img in 48 + if i = 0 then I.hcat [ graph_img; line_img ] 49 + else I.hcat [ graph_continuation; line_img ] 50 + ) in 51 + I.vcat lines 52 + else 53 + I.hcat [ graph_img; content_img ] 54 + ``` 55 + 56 + **How it works:** 57 + 1. Check if content height > 1 58 + 2. Create `graph_continuation` by replacing all node glyphs with `│` 59 + 3. For each line: 60 + - Line 0: Use original `graph_chars` (contains node glyph) 61 + - Line 1+: Use `graph_continuation` (node glyph replaced with `│`) 62 + 4. Vertically stack all lines 63 + 64 + ### 3. Color Scheme 65 + 66 + **Line 1:** 67 + - `change_id`: cyan (bold cyan if working_copy, yellow if empty, lightmagenta if immutable) 68 + - `email`: dim white 69 + - `timestamp`: dim white 70 + - `bookmarks`: bold green 71 + - `commit_id_short`: dim cyan 72 + 73 + **Line 2:** 74 + - `description`: white (dim if empty/preview, lightyellow if wip) 75 + 76 + ## Examples 77 + 78 + ### Simple commit (empty) 79 + ``` 80 + @ ztooztwk eli.jambu@gmail.com 2026-01-15 14:05:59 235795c5 81 + │ (empty) (no description set) 82 + ``` 83 + 84 + ### Commit with description 85 + ``` 86 + ○ smqmznlq eli.jambu@gmail.com 2026-01-15 01:40:23 09a9f33f 87 + │ Add new feature 88 + ``` 89 + 90 + ### Commit with bookmarks 91 + ``` 92 + ◆ noszsqtm eli.jambu@gmail.com 2025-11-22 00:26:06 main master 35b532af 93 + │ remove aarch64 linux because it doesn't seem to work 94 + ``` 95 + 96 + ### Complex graph 97 + ``` 98 + │ ○ nkwwwlnw eli.jambu@gmail.com 2026-01-15 00:30:16 89abd641 99 + │ │ rewrite 100 + ``` 101 + 102 + ## Testing 103 + 104 + - ✅ `dune build` - compiles successfully 105 + - ✅ `dune runtest` - all existing tests pass 106 + - ✅ Multi-line rendering works correctly 107 + - ✅ Graph continuation characters display properly 108 + 109 + ## Files Modified 110 + 111 + 1. **`jj_tui/bin/graph_view.ml`** 112 + - `render_commit_content` (lines 19-79): Two-line format 113 + - `render_graph_row` (lines 81-109): Multi-line graph handling 114 + 115 + ## No Breaking Changes 116 + 117 + - All existing functionality preserved 118 + - Tests pass without modification 119 + - Only visual formatting changed
+4 -2
dune
··· 1 - 2 1 (vendored_dirs forks) 3 2 4 - (env (dev (flags (:standard -warn-error -A)))) 3 + (env 4 + (dev 5 + (flags 6 + (:standard -warn-error -A))))
+8 -7
jj_tui/bin/dune
··· 1 1 (executable 2 2 (public_name jj_tui) 3 3 (name main) 4 - (modes byte native ) 4 + (modes byte native) 5 5 (libraries 6 6 signal 7 7 jj_tui ··· 30 30 ;; (useful in CI), otherwise falls back to `git describe`. If neither is 31 31 ;; available, it writes "unknown". This uses only shell builtins and common 32 32 ;; utilities (no python required). 33 + 33 34 (rule 34 35 (targets version.ml) 35 36 (action 36 - (with-stdout-to version.ml 37 - (run sh -c 38 - "v=${GIT_DESCRIBE:-$(git describe --tags --always --dirty 2>/dev/null || echo unknown)}; 39 - esc=$(printf '%s' \"$v\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\\\"/g'); 40 - printf 'let version = \"%s\"\\n' \"$esc\"" 41 - )))) 37 + (with-stdout-to 38 + version.ml 39 + (run 40 + sh 41 + -c 42 + "v=${GIT_DESCRIBE:-$(git describe --tags --always --dirty 2>/dev/null || echo unknown)};\n esc=$(printf '%s' \"$v\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\\\"/g');\n printf 'let version = \"%s\"\\n' \"$esc\""))))
+43 -48
jj_tui/bin/file_commands.ml
··· 14 14 15 15 (* Define all file commands *) 16 16 let get_command_registry active_files get_commands = 17 - [ { 17 + [ 18 + { 18 19 id = "show_help" 19 20 ; description = "Show help" 20 21 ; sorting_key = 0.0 ··· 36 37 ( "Revision to move file to" 37 38 , fun rev -> 38 39 Cmd 39 - ( [ "squash" 40 - ; "-u" 41 - ; "--keep-emptied" 42 - ; "--from" 43 - ; get_hovered_rev () 44 - ; "--into" 45 - ; rev 46 - ] 47 - @ Lwd.peek active_files ) )) 40 + ([ 41 + "squash" 42 + ; "-u" 43 + ; "--keep-emptied" 44 + ; "--from" 45 + ; get_hovered_rev () 46 + ; "--into" 47 + ; rev 48 + ] 49 + @ Lwd.peek active_files) )) 48 50 } 49 51 ; { 50 52 id = "move_to_child" ··· 55 57 Dynamic_r 56 58 (fun rev -> 57 59 Cmd 58 - ( [ "squash" 59 - ; "-u" 60 - ; "--keep-emptied" 61 - ; "--from" 62 - ; rev 63 - ; "--into" 64 - ; rev ^ "+" 65 - ] 66 - @ Lwd.peek active_files ))) 60 + ([ 61 + "squash"; "-u"; "--keep-emptied"; "--from"; rev; "--into"; rev ^ "+" 62 + ] 63 + @ Lwd.peek active_files))) 67 64 } 68 65 ; { 69 66 id = "move_to_parent" ··· 74 71 Dynamic_r 75 72 (fun rev -> 76 73 Cmd 77 - ( [ "squash" 78 - ; "-u" 79 - ; "--keep-emptied" 80 - ; "--from" 81 - ; rev 82 - ; "--into" 83 - ; rev ^ "-" 84 - ] 85 - @ Lwd.peek active_files ))) 74 + ([ 75 + "squash"; "-u"; "--keep-emptied"; "--from"; rev; "--into"; rev ^ "-" 76 + ] 77 + @ Lwd.peek active_files))) 86 78 } 87 79 ; { 88 80 id = "commit" ··· 93 85 PromptThen 94 86 ( "Commit message" 95 87 , fun message -> 96 - 97 - Fun 98 - (fun _-> (* I need this to work with any commit. So i should split then describe instead*) 99 - let rev=Vars.get_hovered_rev () in 88 + Fun 89 + (fun _ -> 90 + (* I need this to work with any commit. So i should split then describe instead*) 91 + let rev = Vars.get_hovered_rev () in 100 92 jj 101 - ( [ "split";"-r"; rev; "-m";message; "--insert-before"; "@" ] 102 - @ Lwd.peek active_files )|>ignore; 103 - ))) 93 + ([ "split"; "-r"; rev; "-m"; message; "--insert-before"; "@" ] 94 + @ Lwd.peek active_files) 95 + |> ignore) )) 104 96 } 105 97 ; { 106 98 id = "abandon" ··· 116 108 ^ (selected |> String.concat "\n") 117 109 ^ "\nin rev " 118 110 ^ rev) 119 - (Cmd (["restore"; "--to"; rev; "--from"; rev ^ "-"] @ selected)))) 111 + (Cmd ([ "restore"; "--to"; rev; "--from"; rev ^ "-" ] @ selected)))) 120 112 } 121 113 ; { 122 114 id = "absorb" 123 - ; description = "Move changes from this revision to the nearest parent that modified the same lines" 115 + ; description = 116 + "Move changes from this revision to the nearest parent that modified the same \ 117 + lines" 124 118 ; sorting_key = 5.0 125 119 ; make_cmd = 126 120 (fun () -> ··· 132 126 ^ (selected |> String.concat "\n") 133 127 ^ "\nin rev " 134 128 ^ rev) 135 - (Cmd (["absorb"; "--from"; rev] @ selected)))) 129 + (Cmd ([ "absorb"; "--from"; rev ] @ selected)))) 136 130 } 137 131 ; { 138 132 id = "absorb-into" ··· 143 137 PromptThen 144 138 ( "Revision to move file to" 145 139 , fun dest -> 146 - Dynamic_r 147 - (fun rev -> 148 - let selected = Lwd.peek active_files in 149 - confirm_prompt 150 - ("absorb all changes to:\n" 151 - ^ (selected |> String.concat "\n") 152 - ^ "\nin rev " 153 - ^ rev) 154 - (Cmd (["absorb"; "--from"; rev;"--to"; dest] @ selected))))) 140 + Dynamic_r 141 + (fun rev -> 142 + let selected = Lwd.peek active_files in 143 + confirm_prompt 144 + ("absorb all changes to:\n" 145 + ^ (selected |> String.concat "\n") 146 + ^ "\nin rev " 147 + ^ rev) 148 + (Cmd ([ "absorb"; "--from"; rev; "--to"; dest ] @ selected))) )) 155 149 } 156 150 ; { 157 151 id = "undo" ··· 163 157 |> List.to_seq 164 158 |> Seq.map (fun x -> x.id, x) 165 159 |> Hashtbl.of_seq 166 - end 160 + ;; 161 + end
+7 -7
jj_tui/bin/file_view.ml
··· 12 12 13 13 (* Import file commands *) 14 14 module FileCommands = File_commands.Make (Vars) 15 - 16 15 open Jj_tui.Key_map 16 + 17 17 let active_files = Lwd.var [ "" ] 18 18 19 19 (* Remove the hardcoded make_command_mapping function and use the dynamic one *) 20 20 let command_mapping = ref None 21 - 21 + 22 22 let rec get_command_mapping () = 23 23 match !command_mapping with 24 - | Some mapping -> mapping 24 + | Some mapping -> 25 + mapping 25 26 | None -> 26 27 let key_map = (Lwd.peek ui_state.config).key_map.file in 27 28 let registry = FileCommands.get_command_registry active_files get_command_mapping in ··· 29 30 command_mapping := Some mapping; 30 31 mapping 31 32 ;; 32 - 33 + 33 34 let hovered_var = ref "./" 34 35 35 36 let file_view ~focus summary_focus = ··· 66 67 | `Enter, [] -> 67 68 Focus.request_reversable summary_focus; 68 69 `Handled 69 - | k -> 70 - handleInputs (get_command_mapping ()) k 70 + | k -> 71 + handleInputs (get_command_mapping ()) k 71 72 | _ -> 72 73 `Unhandled) 73 74 in ··· 79 80 in 80 81 ui 81 82 ;; 82 - 83 83 end
+3 -9
jj_tui/bin/global_vars.ml
··· 41 41 } 42 42 43 43 let get_unique_id maybe_unique_rev = 44 - match maybe_unique_rev with 45 - | Unique s -> 46 - s 47 - | Duplicate s -> 48 - s 44 + match maybe_unique_rev with Unique s -> s | Duplicate s -> s 49 45 ;; 50 46 51 47 (** Global variables for the ui. Here we keep anything that's just a pain to pipe around*) ··· 82 78 ; jj_show_promise = ref @@ Promise.of_value () 83 79 ; jj_branches = Lwd.var I.empty 84 80 ; jj_change_files = Lwd.var [] 85 - ; hovered_revision = 86 - Lwd.var (Unique "@") 87 - ; selected_revisions = 88 - Lwd.var [ Unique "@"; ] 81 + ; hovered_revision = Lwd.var (Unique "@") 82 + ; selected_revisions = Lwd.var [ Unique "@" ] 89 83 ; revset = Lwd.var None 90 84 ; graph_revs = Lwd.var [||] 91 85 ; input = Lwd.var `Normal
+11 -17
jj_tui/bin/graph_commands.ml
··· 50 50 let rev_args = List.concat_map (fun r -> [ "-r"; r ]) revs in 51 51 let title = Printf.sprintf "Git push (%s) will:" remote in 52 52 let dry_run_cmd = 53 - [ "git"; "push"; "--allow-new"; "--dry-run"; "--remote"; remote ] 54 - @ rev_args 53 + [ "git"; "push"; "--allow-new"; "--dry-run"; "--remote"; remote ] @ rev_args 55 54 in 56 55 let real_cmd = 57 56 Cmd_async 58 57 ( "pushing to remote..." 59 - , [ "git"; "push"; "--allow-new"; "--remote"; remote ] 60 - @ rev_args ) 58 + , [ "git"; "push"; "--allow-new"; "--remote"; remote ] @ rev_args ) 61 59 in 62 60 confirm_dry_run_prompt ~title ~dry_run_cmd ~real_cmd) 63 61 in ··· 66 64 (fun () -> 67 65 let title = Printf.sprintf "Git push (%s) will:" remote in 68 66 let dry_run_cmd = 69 - [ "git"; "push";"--deleted"; "--allow-new"; "--dry-run"; "--remote"; remote ] 70 - 67 + [ "git"; "push"; "--deleted"; "--allow-new"; "--dry-run"; "--remote"; remote ] 71 68 in 72 69 let real_cmd = 73 70 Cmd_async 74 71 ( "pushing to remote..." 75 - , [ "git"; "push"; "--allow-new"; "--deleted"; "--remote"; remote ] 76 - ) 72 + , [ "git"; "push"; "--allow-new"; "--deleted"; "--remote"; remote ] ) 77 73 in 78 74 confirm_dry_run_prompt ~title ~dry_run_cmd ~real_cmd) 79 75 in ··· 90 86 ; sort_key = 0. 91 87 ; description = Printf.sprintf "git push hovered to %s" remote 92 88 ; cmd = push_cmd () 93 - } ); 94 - ( Key.key_of_string_exn "P" 89 + } ) 90 + ; ( Key.key_of_string_exn "P" 95 91 , { 96 92 key = Key.key_of_string_exn "P" 97 93 ; sort_key = 0.1 98 94 ; description = Printf.sprintf "git push all to %s" remote 99 - ; cmd = push_all_cmd() 95 + ; cmd = push_all_cmd () 100 96 } ) 101 97 ; ( Key.key_of_string_exn "f" 102 98 , { ··· 356 352 let rev_args = revs |> List.concat_map (fun x -> [ "-r"; x ]) in 357 353 let title = "Git push will:" in 358 354 let dry_run_cmd = 359 - [ "git"; "push"; "--allow-new"; "--dry-run" ] @ rev_args 355 + [ "git"; "push"; "--allow-new"; "--dry-run" ] @ rev_args 360 356 in 361 357 let real_cmd = 362 358 Cmd_async 363 - ( "pushing to remote..." 364 - , [ "git"; "push"; "--allow-new" ] @ rev_args ) 359 + ("pushing to remote...", [ "git"; "push"; "--allow-new" ] @ rev_args) 365 360 in 366 361 confirm_dry_run_prompt ~title ~dry_run_cmd ~real_cmd)) 367 362 } ··· 375 370 (fun () -> 376 371 let title = "Git push will:" in 377 372 let dry_run_cmd = 378 - [ "git"; "push"; "--deleted"; "--allow-new"; "--dry-run" ] 373 + [ "git"; "push"; "--deleted"; "--allow-new"; "--dry-run" ] 379 374 in 380 375 let real_cmd = 381 376 Cmd_async 382 - ( "pushing to remote..." 383 - , [ "git"; "push"; "--deleted"; "--allow-new" ] ) 377 + ("pushing to remote...", [ "git"; "push"; "--deleted"; "--allow-new" ]) 384 378 in 385 379 confirm_dry_run_prompt ~title ~dry_run_cmd ~real_cmd)) 386 380 }
+90 -74
jj_tui/bin/graph_view.ml
··· 16 16 (* Import graph commands *) 17 17 module GraphCommands = Graph_commands.Make (Vars) 18 18 19 - (** Render commit content for a node - shows change_id, author, timestamp, description, bookmarks *) 20 - let render_commit_content (node : Render_jj_graph.node) : Notty.image = 21 - let open Notty in 22 - let open Notty.A in 23 - let styled_text attr text = I.string attr text in 24 - let change_id_short = 25 - String.sub node.change_id 0 (min 8 (String.length node.change_id)) 26 - in 27 - let author_name = 28 - match String.split_on_char '@' node.author_email with 29 - | name :: _ -> 30 - name 19 + (* Use the library's render function for commit content *) 20 + let render_commit_content = Commit_render.render_commit_content 21 + 22 + (** Group rows by their owning node. Each group is (node_row, continuation_rows). 23 + Each NodeRow starts a new group containing it and all following non-NodeRows until 24 + the next NodeRow. *) 25 + let group_rows_by_node (rows : Render_jj_graph.graph_row_output list) : 26 + (Render_jj_graph.graph_row_output * Render_jj_graph.graph_row_output list) list 27 + = 28 + let open Render_jj_graph in 29 + let rec loop acc current_group = function 31 30 | [] -> 32 - node.author_email 33 - in 34 - let description_line = 35 - match String.split_on_char '\n' node.description with 36 - | first :: _ when String.trim first <> "" -> 37 - String.trim first 38 - | _ -> 39 - "(no description set)" 40 - in 41 - let parts = ref [] in 42 - let change_id_attr = 43 - if node.is_preview 44 - then fg lightblack ++ st dim 45 - else if node.working_copy 46 - then fg lightcyan ++ st bold 47 - else if node.immutable 48 - then fg lightmagenta 49 - else if node.empty 50 - then fg yellow 51 - else fg cyan 31 + List.rev (match current_group with Some g -> g :: acc | None -> acc) 32 + | row :: rest -> 33 + (match row.row_type with 34 + | NodeRow -> 35 + let acc = match current_group with Some g -> g :: acc | None -> acc in 36 + loop acc (Some (row, [])) rest 37 + | _ -> 38 + (match current_group with 39 + | Some (node_row, conts) -> 40 + loop acc (Some (node_row, conts @ [ row ])) rest 41 + | None -> 42 + (* Orphan row, shouldn't happen, but skip it *) 43 + loop acc None rest)) 52 44 in 53 - parts := styled_text change_id_attr change_id_short :: !parts; 54 - parts := styled_text (fg white ++ st dim) (" " ^ author_name) :: !parts; 55 - parts := styled_text (fg white ++ st dim) (" " ^ node.author_timestamp) :: !parts; 56 - if List.length node.bookmarks > 0 57 - then ( 58 - let bookmarks_str = " (" ^ String.concat ", " node.bookmarks ^ ")" in 59 - parts := styled_text (fg green ++ st bold) bookmarks_str :: !parts); 60 - let desc_attr = 61 - if node.is_preview || node.empty 62 - then fg white ++ st dim 63 - else if node.wip 64 - then fg lightyellow 65 - else fg white 66 - in 67 - parts := styled_text desc_attr (" " ^ description_line) :: !parts; 68 - !parts |> List.rev |> I.hcat 45 + loop [] None rows 69 46 ;; 70 47 71 - (** Render a graph row by combining graph prefix with content *) 72 - let render_graph_row 73 - (row : Render_jj_graph.graph_row_output) 74 - ~(render_content : Render_jj_graph.node -> Notty.image) : Notty.image 48 + (** Render a node group by distributing content lines across available rows. 49 + Returns a list of (row, rendered_image) pairs. *) 50 + let render_node_group 51 + ((node_row, continuation_rows) : 52 + Render_jj_graph.graph_row_output * Render_jj_graph.graph_row_output list) 53 + ~(render_content : Render_jj_graph.node -> Notty.image list) : 54 + (Render_jj_graph.graph_row_output * Notty.image) list 75 55 = 76 56 let open Notty in 77 - let graph_img = I.string A.empty row.graph_chars in 78 - match row.row_type with 79 - | NodeRow -> 80 - let content_img = render_content row.node in 81 - I.hcat [ graph_img; content_img ] 82 - | LinkRow | PadRow | TermRow -> 83 - graph_img 57 + let open Render_jj_graph in 58 + let content_lines = render_content node_row.node in 59 + 60 + let available_rows = node_row :: continuation_rows in 61 + let result = ref [] in 62 + (* Distribute content lines across available rows *) 63 + List.iteri 64 + (fun i row -> 65 + let graph_img = row.graph_image in 66 + let combined = 67 + if i < List.length content_lines 68 + then I.hcat [ graph_img; List.nth content_lines i ] 69 + else graph_img 70 + in 71 + result := (row, combined) :: !result) 72 + available_rows; 73 + (* If content needs more lines than available, add synthetic continuation rows *) 74 + if List.length content_lines > List.length available_rows 75 + then ( 76 + let node_glyphs = [ "○"; "@"; "◌"; "◆" ] in 77 + let synthetic_graph = 78 + let chars = node_row.graph_chars in 79 + let replaced = ref chars in 80 + List.iter 81 + (fun glyph -> 82 + replaced := Str.global_replace (Str.regexp_string glyph) "│" !replaced) 83 + node_glyphs; 84 + I.string A.empty !replaced 85 + in 86 + for i = List.length available_rows to List.length content_lines - 1 do 87 + let line_img = List.nth content_lines i in 88 + result := (node_row, I.hcat [ synthetic_graph; line_img ]) :: !result 89 + done); 90 + List.rev !result 84 91 ;; 85 92 86 93 let bookmark_select_prompt get_bookmark_list name func = ··· 137 144 let state = 138 145 Render_jj_graph.{ depth = 0; columns = [||]; pending_joins = [] } 139 146 in 140 - let rendered_rows = Render_jj_graph.render_nodes_structured state nodes in 147 + let rendered_rows = Render_jj_graph.render_nodes_structured state nodes ~node_attr:(Commit_render.graph_node_attr) in 141 148 error_var $= None; 142 149 rendered_rows, rev_ids 143 150 with ··· 149 156 (*We will make two arrays, one with both selectable and filler and one with only selectable*) 150 157 let selectable_idx = ref 0 in 151 158 let selectable_items = Array.make (Array.length rev_ids) (Obj.magic ()) in 159 + (* Group rows by node and render each group with content distribution *) 160 + let grouped_rows = group_rows_by_node rendered_rows in 152 161 let items = 153 - rendered_rows 154 - |> List.map (fun (row : Render_jj_graph.graph_row_output) -> 155 - match row.row_type with 156 - | NodeRow -> 157 - let ui = 158 - W.Lists.selectable_item 159 - (render_graph_row row ~render_content:render_commit_content |> Ui.atom) 160 - in 162 + grouped_rows 163 + |> List.concat_map (fun group -> 164 + let rendered_group = 165 + render_node_group group ~render_content:render_commit_content 166 + in 167 + (* Convert rendered group to list items: first is Selectable, rest are Fillers *) 168 + match rendered_group with 169 + | [] -> 170 + [] 171 + | (_first_row, first_img) :: rest_rows -> 161 172 let id = rev_ids.(!selectable_idx) in 173 + let selectable_ui = W.Lists.selectable_item (first_img |> Ui.atom) in 162 174 let data = 163 175 W.Lists. 164 176 { 165 - ui 177 + ui = selectable_ui 166 178 ; id = id |> Global_vars.get_unique_id |> String.hash 167 179 ; data = rev_ids.(!selectable_idx) 168 180 } 169 181 in 170 - (*Add to our selectable array*) 182 + (* Add to our selectable array *) 171 183 Array.set selectable_items !selectable_idx data; 172 184 selectable_idx := !selectable_idx + 1; 173 - W.Lists.(Selectable data) 174 - | LinkRow | PadRow | TermRow -> 175 - let graph_img = I.string A.empty row.graph_chars in 176 - W.Lists.(Filler (graph_img |> Ui.atom |> Lwd.pure))) 185 + let first_item = W.Lists.(Selectable data) in 186 + (* All other rows in the group become fillers *) 187 + let filler_items = 188 + List.map 189 + (fun (_row, img) -> W.Lists.(Filler (img |> Ui.atom |> Lwd.pure))) 190 + rest_rows 191 + in 192 + first_item :: filler_items) 177 193 |> Array.of_list 178 194 in 179 195 items
+40 -38
jj_tui/bin/jj_commands.ml
··· 6 6 open Jj_tui.Key_map 7 7 open Jj_tui.Key 8 8 open Jj_tui 9 - 10 9 open Log 11 10 12 11 (** Internal to this module. I'm trying this out as a way to avoid .mli files*) ··· 159 158 let space_command = 160 159 render_command_line ~indent_level:0 "<space>" "toggle selection (multi-select)" 161 160 in 162 - let enter_command= 163 - render_command_line ~indent_level:0 "<Enter>" "Focus status view to enlarge and scroll diff" 161 + let enter_command = 162 + render_command_line 163 + ~indent_level:0 164 + "<Enter>" 165 + "Focus status view to enlarge and scroll diff" 164 166 in 165 - ((commands |> render_commands) @ if include_arrows then [ move_command; space_command;enter_command ] else []) 167 + ((commands |> render_commands) 168 + @ if include_arrows then [ move_command; space_command; enter_command ] else []) 166 169 |> I.vcat 167 170 |> Ui.atom 168 171 |> Lwd.pure 169 172 |> W.Scroll.area 170 173 ;; 171 174 172 - let rec handleCommand description (cmd:string command_variant) = 173 - [%log 174 - info "Handling command. description: %s" description]; 175 + let rec handleCommand description (cmd : string command_variant) = 176 + [%log info "Handling command. description: %s" description]; 175 177 let noOut args = 176 178 let _ = args in 177 179 let _result = jj args in ··· 219 221 ()) 220 222 } 221 223 in 222 - 223 224 let change_view view = Lwd.set ui_state.view view in 224 225 let send_cmd args = change_view (`Cmd_I args) in 225 226 match cmd with 226 - | Cmd_async (loading_msg,args) -> 227 - jj_async 228 - args 229 - ~on_start:(fun () -> show_popup @@ Some (W.hbox [ Nottui_picos.Widgets.throbber ;(W.string (" "^loading_msg)|>Lwd.pure) ], "loading...")) 230 - ~on_success:(fun _ -> 231 - Global_funcs.update_status ~cause_snapshot:true (); 232 - show_popup None) 233 - ~on_error:(fun code str -> 234 - handle_jj_error 235 - ~cmd:("jj " ^ (args |> String.concat " ")) 236 - ~error:(Printf.sprintf "Exited with code %i; Message:\n%s" code str); 237 - ); 227 + | Cmd_async (loading_msg, args) -> 228 + jj_async 229 + args 230 + ~on_start:(fun () -> 231 + show_popup 232 + @@ Some 233 + ( W.hbox 234 + [ 235 + Nottui_picos.Widgets.throbber 236 + ; W.string (" " ^ loading_msg) |> Lwd.pure 237 + ] 238 + , "loading..." )) 239 + ~on_success:(fun _ -> 240 + Global_funcs.update_status ~cause_snapshot:true (); 241 + show_popup None) 242 + ~on_error:(fun code str -> 243 + handle_jj_error 244 + ~cmd:("jj " ^ (args |> String.concat " ")) 245 + ~error:(Printf.sprintf "Exited with code %i; Message:\n%s" code str)); 238 246 raise Handled 239 247 | Cmd_I args -> 240 248 show_popup None; ··· 350 358 ; cmd = 351 359 Fun 352 360 (fun _ -> 353 - show_popup@@ 354 - Some 361 + show_popup 362 + @@ Some 355 363 (commands_list_ui ~include_arrows:true (make_default_list ()), "Help"); 356 364 ui_state.input $= `Mode (fun _ -> `Unhandled)) 357 365 } ··· 381 389 Fun 382 390 (fun _ -> 383 391 let subcmds = 384 - [ (let key = key_of_string_exn "y" in 385 - ( key 386 - , { key 387 - ; sort_key = 0. 388 - ; description = "proceed" 389 - ; cmd = real_cmd 390 - } )) 392 + [ 393 + (let key = key_of_string_exn "y" in 394 + key, { key; sort_key = 0.; description = "proceed"; cmd = real_cmd }) 391 395 ; (let key = key_of_string_exn "n" in 392 396 ( key 393 - , { key 397 + , { 398 + key 394 399 ; sort_key = 1. 395 400 ; description = "exit" 396 401 ; cmd = ··· 418 423 Fun 419 424 (fun _ -> 420 425 let subcmds = 421 - [ (let key = key_of_string_exn "y" in 422 - ( key 423 - , { key 424 - ; sort_key = 0. 425 - ; description = "proceed" 426 - ; cmd = real_cmd 427 - } )) 426 + [ 427 + (let key = key_of_string_exn "y" in 428 + key, { key; sort_key = 0.; description = "proceed"; cmd = real_cmd }) 428 429 ; (let key = key_of_string_exn "n" in 429 430 ( key 430 - , { key 431 + , { 432 + key 431 433 ; sort_key = 1. 432 434 ; description = "exit" 433 435 ; cmd =
+48 -45
jj_tui/bin/jj_process.ml
··· 19 19 let output = Buffer.create buffer_size in 20 20 let rec read_loop () = 21 21 match Unix.read fd buffer 0 buffer_size with 22 - | 0 -> Buffer.contents output (* EOF reached *) 22 + | 0 -> 23 + Buffer.contents output (* EOF reached *) 23 24 | n -> 24 25 Buffer.add_subbytes output buffer 0 n; 25 26 read_loop () 26 27 | exception Unix.Unix_error (Unix.EINTR, _, _) -> 27 28 read_loop () 28 29 | exception Unix.Unix_error (Unix.EBADF, _, _) -> 29 - Buffer.contents output (* Handle EBADF error *) 30 + Buffer.contents output (* Handle EBADF error *) 30 31 | exception Unix.Unix_error (Unix.EAGAIN, _, _) -> 31 - Unix.sleepf 0.01; (* Short sleep to avoid busy waiting *) 32 + Unix.sleepf 0.01; 33 + (* Short sleep to avoid busy waiting *) 32 34 read_loop () 33 35 in 34 36 read_loop () 37 + ;; 35 38 36 39 module Make (Vars : Global_vars.Vars) = struct 37 40 (** Makes a new process that has acess to all input and output ··· 40 43 let stdout = Unix.stdout in 41 44 let stdin = Unix.stdin in 42 45 (* Create a pipe for stderr to capture it *) 43 - let stderr_r, stderr_w = Unix.pipe () in 46 + let stderr_r, stderr_w = Unix.pipe () in 44 47 let pid = Unix.create_process command.(0) command stdin stdout stderr_w in 45 48 (* Close write end in parent *) 46 - Unix.close stderr_w; 47 - let _, status = Unix.waitpid [] pid in 48 - (* Read stderr contents *) 49 - let stderr_content = read_fd_contents stderr_r in 50 - Unix.close stderr_r; 51 - status, stderr_content 49 + Unix.close stderr_w; 50 + let _, status = Unix.waitpid [] pid in 51 + (* Read stderr contents *) 52 + let stderr_content = read_fd_contents stderr_r in 53 + Unix.close stderr_r; 54 + status, stderr_content 52 55 ;; 53 56 54 57 (* ··· 134 137 let@ stdout_o, stdout_i = 135 138 finally 136 139 (fun (o, i) -> 137 - Unix.close o; 138 - dispose i) 140 + Unix.close o; 141 + dispose i) 139 142 (Picos_io.Unix.pipe ~cloexec:true) 140 143 in 141 144 let@ stdin_o, stdin_i = 142 145 finally 143 146 (fun (o, i) -> 144 - Unix.close i; 145 - dispose o) 147 + Unix.close i; 148 + dispose o) 146 149 (Picos_io.Unix.pipe ~cloexec:true) 147 150 in 148 151 let@ stderr_o, stderr_i = 149 152 finally 150 153 (fun (o, i) -> 151 - Unix.close o; 152 - dispose i) 154 + Unix.close o; 155 + dispose i) 153 156 (Picos_io.Unix.pipe ~cloexec:true) 154 157 in 155 158 (* This should ensure that all children processes are killed before we cleanup the pipes*) ··· 158 161 let@ pid = 159 162 finally 160 163 (fun pid -> 161 - (* if the process didn't finish we will kill the process and then wait it's pid to release the pid*) 162 - if not !isDone 163 - then ( 164 - try 165 - [%log debug "pid: %i Cleaning up cancelled command %s" pid (args |> String.concat " ")]; 166 - Unix.kill pid Sys.sigkill; 167 - Unix.waitpid [ Unix.WUNTRACED ] pid |> ignore 168 - with 169 - | _ -> 170 - ())) 164 + (* if the process didn't finish we will kill the process and then wait it's pid to release the pid*) 165 + if not !isDone 166 + then ( 167 + try 168 + [%log 169 + debug 170 + "pid: %i Cleaning up cancelled command %s" 171 + pid 172 + (args |> String.concat " ")]; 173 + Unix.kill pid Sys.sigkill; 174 + Unix.waitpid [ Unix.WUNTRACED ] pid |> ignore 175 + with 176 + | _ -> 177 + ())) 171 178 (fun _ -> 172 - Unix.create_process_env 173 - cmd 174 - (cmd :: args |> Array.of_list) 175 - (Unix.environment ()) 176 - stdin_o 177 - stdout_i 178 - stderr_i) 179 + Unix.create_process_env 180 + cmd 181 + (cmd :: args |> Array.of_list) 182 + (Unix.environment ()) 183 + stdin_o 184 + stdout_i 185 + stderr_i) 179 186 in 180 - 181 - [%log debug "pid: %i started" pid ]; 187 + [%log debug "pid: %i started" pid]; 182 188 let prom = Flock.fork_as_promise (fun () -> Unix.waitpid [] pid) in 183 189 (* Close unused pipe ends in the parent process *) 184 190 Unix.close stdout_i; ··· 193 199 isDone := true; 194 200 (* let stderr = read_fd_to_end stderr_i in *) 195 201 (* let stdout= ""in *) 196 - code, status, stdout, stderr,pid 202 + code, status, stdout, stderr, pid 197 203 ;; 198 - 199 204 200 205 (* Ui_loop.run (Lwd.pure (W.printf "Hello world"));; *) 201 206 let cmdArgs cmd args = 202 207 let start_time = Unix.gettimeofday () in 203 - let code, status, out_content, err_content,pid = picos_process cmd args in 208 + let code, status, out_content, err_content, pid = picos_process cmd args in 204 209 let end_time = Unix.gettimeofday () in 205 - let exit_code_text= 210 + let exit_code_text = 206 211 match status with 207 212 | Unix.WEXITED code -> 208 213 Printf.sprintf "exit: %i" code 209 - | Unix.WSIGNALED x-> 214 + | Unix.WSIGNALED x -> 210 215 Printf.sprintf "signalled: %i" x 211 - | Unix.WSTOPPED x-> 216 + | Unix.WSTOPPED x -> 212 217 Printf.sprintf "stopped: %i" x 213 - in 214 - 218 + in 215 219 [%log 216 220 debug 217 221 "Executing pid:%i %s '%s %s' took: %fms " ··· 318 322 in 319 323 on_start (); 320 324 Picos_std_structured.Flock.fork (fun () -> 321 - try run () 322 - with 325 + try run () with 323 326 | exn -> 324 327 let msg = Printexc.to_string exn in 325 328 [%log warn "Exception in jj_async: %s" msg];
+7 -7
jj_tui/bin/jj_widgets.ml
··· 37 37 data = name 38 38 ; id = name |> String.hash 39 39 ; ui = 40 - str 40 + str 41 41 |> Jj_tui.AnsiReverse.colored_string 42 42 |> Ui.atom 43 43 |> Ui.resize ~w:100 ~h:1 ~mw:100 ··· 79 79 let lines = String.split_on_char '\n' log in 80 80 lines 81 81 |> List.filter_map (fun line -> 82 - if line |>String.trim|> String.length =0 82 + if line |> String.trim |> String.length = 0 83 83 then None 84 84 else ( 85 85 match Base.String.lsplit2 ~on:' ' line with 86 - | Some (name, _) -> Some (name, line) 87 - | None -> Some (line, line))) 86 + | Some (name, _) -> 87 + Some (name, line) 88 + | None -> 89 + Some (line, line))) 88 90 ;; 89 91 90 92 let get_remotes_selectable () = ··· 95 97 data = name 96 98 ; id = name |> String.hash 97 99 ; ui = 98 - str 100 + str 99 101 |> Jj_tui.AnsiReverse.colored_string 100 102 |> Ui.atom 101 103 |> Ui.resize ~w:100 ~h:1 ~mw:100 ··· 208 210 | _ -> 209 211 `Unhandled) 210 212 ;; 211 - 212 - 213 213 end
+12 -7
jj_tui/bin/main.ml
··· 4 4 module Jj_ui = Jj_ui.Make (Vars) 5 5 open Picos_std_structured 6 6 open Jj_tui.Logging 7 + 7 8 let () = 8 9 (* Handle --version / -v early. A module `Version` is expected to be 9 10 available (provided by `version.ml`, generated at build-time or present 10 11 as a fallback). It should expose `val version : string`. Use 11 12 `Version.version` here so the code refers to that module explicitly. *) 12 - if Array.length Sys.argv > 1 then 13 + if Array.length Sys.argv > 1 14 + then ( 13 15 match Sys.argv.(1) with 14 16 | "--version" | "-v" -> 15 17 print_endline Version.version; 16 18 exit 0 17 - | _ -> (); 18 - Ui.global_config.border_style<-Nottui.Ui.Border.unicode_rounded; 19 - Ui.global_config.border_style_focused<-Nottui.Ui.Border.unicode_rounded; 20 - (* Ui.global_config.border_attr<-A.empty; *) 21 - (* Ui.global_config.border_attr_focused<-; *) 19 + | _ -> 20 + (); 21 + Ui.global_config.border_style <- Nottui.Ui.Border.unicode_rounded; 22 + Ui.global_config.border_style_focused <- Nottui.Ui.Border.unicode_rounded) 22 23 ;; 24 + 25 + (* Ui.global_config.border_attr<-A.empty; *) 26 + (* Ui.global_config.border_attr_focused<-; *) 27 + 23 28 let await_read_unix fd timeout : [ `Ready | `NotReady ] = 24 29 let rec select () = 25 30 match Unix.select [ fd ] [] [ fd ] timeout with ··· 61 66 if term_width <> prev_term_width || term_height <> prev_term_height 62 67 then Lwd.set Vars.term_width_height (term_width, term_height) 63 68 in 64 - Nottui_picos.Ui_loop.run ~tick ~term ~renderer ~quit root 69 + Nottui_picos.Ui_loop.run ~tick ~term ~renderer ~quit root 65 70 ;; 66 71 67 72 let start_ui () =
+17 -19
jj_tui/bin/show_view.ml
··· 80 80 ;; 81 81 82 82 let get_latest_message cursor = 83 - 84 - let rec seek_latest last cursor= 85 - let peeked=Stream.peek_opt cursor in 86 - match peeked with 87 - |Some (last,new_cursor)-> 88 - seek_latest last new_cursor 89 - |None-> 90 - [%log debug "skipping to next status because two were queued"]; 91 - (last,cursor) 92 - in 93 - let msg, new_cursor = cursor |> Stream.read in 94 - 95 - (*little 50ms delay to let us move to the next one if it's ready*) 96 - Picos.Fiber.sleep ~seconds:0.05; 97 - (*if the queue isn't empty just skip the current because we really only ever want the newest*) 98 - seek_latest msg new_cursor 99 - 83 + let rec seek_latest last cursor = 84 + let peeked = Stream.peek_opt cursor in 85 + match peeked with 86 + | Some (last, new_cursor) -> 87 + seek_latest last new_cursor 88 + | None -> 89 + [%log debug "skipping to next status because two were queued"]; 90 + last, cursor 91 + in 92 + let msg, new_cursor = cursor |> Stream.read in 93 + (*little 50ms delay to let us move to the next one if it's ready*) 94 + Picos.Fiber.sleep ~seconds:0.05; 95 + (*if the queue isn't empty just skip the current because we really only ever want the newest*) 96 + seek_latest msg new_cursor 97 + ;; 100 98 101 99 (* Wait for messages to come in the stream. 102 100 When a message comes, we try to render it. ··· 109 107 let cursor = ref (Stream.tap stream) in 110 108 while true do 111 109 [%log debug "waiting for next status"]; 112 - let msg,new_cursor=get_latest_message !cursor in 113 - cursor:=new_cursor; 110 + let msg, new_cursor = get_latest_message !cursor in 111 + cursor := new_cursor; 114 112 [%log debug "cancelling older status because of new message"]; 115 113 Promise.terminate_after ~seconds:0. !current_summary_computation; 116 114 Promise.terminate_after ~seconds:0. !current_detail_computation;
+107
jj_tui/lib/commit_render.ml
··· 1 + (** 2 + `commit_render.ml` 3 + 4 + Module for rendering commit nodes to Notty images. 5 + Handles rendering commit metadata with proper styling including shortest unique prefix highlighting. 6 + *) 7 + 8 + open Notty 9 + 10 + (** Render an ID with prefix highlighting. 11 + The prefix gets the full color attribute, while the rest gets a dimmed version. *) 12 + let render_id ~prefix_attr ~rest_attr ~prefix ~rest = 13 + if String.length rest > 0 14 + then I.(string prefix_attr prefix <|> string rest_attr rest) 15 + else I.string prefix_attr prefix 16 + ;; 17 + 18 + (** Color for the graph node glyph based on node state. *) 19 + let graph_node_attr (node : Render_jj_graph.node) : Notty.A.t = 20 + let open Notty.A in 21 + if node.is_preview 22 + then fg lightblack 23 + else if node.working_copy 24 + then fg green ++ st bold 25 + else if node.immutable 26 + then fg cyan 27 + else fg white 28 + ;; 29 + 30 + 31 + (** The amount of padding to add to the left of the commit content. *) 32 + let pad_amount = 2 33 + let add_padding img = I.pad ~l:pad_amount img 34 + (** Render commit content for a node - shows change_id, author, timestamp, description, bookmarks. 35 + Matches original jj format: 36 + Line 1: change_id email timestamp commit_id_short 37 + Line 2: (empty) description 38 + *) 39 + let render_commit_content (node : Render_jj_graph.node) : Notty.image list = 40 + let open Notty in 41 + let open Notty.A in 42 + 43 + let magenta= if node.working_copy then lightmagenta else magenta in 44 + (*make style bold if working copy*) 45 + let bs = if node.working_copy then st bold else st A.no_style in 46 + let styled_text attr text = I.string attr text in 47 + let description_line = 48 + match String.split_on_char '\n' node.description with 49 + | first :: _ when String.trim first <> "" -> 50 + String.trim first 51 + | _ -> 52 + "(no description set)" 53 + in 54 + (* Line 1: change_id email timestamp bookmarks commit_id_short *) 55 + let line1_parts = ref [] in 56 + (* Determine base color for change_id based on node state *) 57 + 58 + (* Render change_id with prefix highlighting *) 59 + let change_id_prefix_attr = 60 + fg magenta ++ st bold 61 + in 62 + let change_id_rest_attr = fg lightblack ++bs in 63 + let change_id_img = 64 + render_id 65 + ~prefix_attr:change_id_prefix_attr 66 + ~rest_attr:change_id_rest_attr 67 + ~prefix:node.change_id_prefix 68 + ~rest:node.change_id_rest 69 + in 70 + line1_parts := change_id_img :: !line1_parts; 71 + (* Author email and timestamp *) 72 + line1_parts 73 + := styled_text (fg yellow ++bs) (" " ^ node.author_email) :: !line1_parts; 74 + line1_parts 75 + := styled_text (fg cyan++bs ) (" " ^ node.author_timestamp) :: !line1_parts; 76 + (* Add bookmarks after timestamp if they exist *) 77 + if List.length node.bookmarks > 0 78 + then ( 79 + let bookmarks_str = " " ^ String.concat " " node.bookmarks in 80 + line1_parts := styled_text (fg magenta ) bookmarks_str :: !line1_parts); 81 + (* Render commit_id with prefix highlighting *) 82 + let commit_id_prefix_attr = (if node.working_copy then fg lightblue else fg blue) ++ st bold in 83 + let commit_id_rest_attr = fg lightblack ++bs in 84 + let commit_id_img = 85 + render_id 86 + ~prefix_attr:commit_id_prefix_attr 87 + ~rest_attr:commit_id_rest_attr 88 + ~prefix:(" " ^ node.commit_id_prefix) 89 + ~rest:node.commit_id_rest 90 + in 91 + line1_parts := commit_id_img :: !line1_parts; 92 + let line1 = !line1_parts |> List.rev |> I.hcat in 93 + (* Line 2: (empty) description *) 94 + let desc_attr = 95 + ( if node.is_preview || node.empty 96 + then lightgreen 97 + else if node.description="" 98 + then yellow 99 + else white) 100 + |>fg|> (++) bs 101 + in 102 + let description_with_prefix = 103 + if node.empty then "(empty) " ^ description_line else description_line 104 + in 105 + let line2 = styled_text desc_attr description_with_prefix in 106 + [add_padding line1; add_padding line2 ] 107 + ;;
+189
jj_tui/lib/commit_render_tests.ml
··· 1 + (** 2 + `commit_render_tests.ml` 3 + 4 + Tests for commit rendering with Notty image output. 5 + *) 6 + 7 + open Commit_render 8 + 9 + (** Render Notty image to string for testing (text-only, no ANSI codes) *) 10 + let image_to_string img = 11 + let buf = Buffer.create 256 in 12 + let w, h = Notty.I.(width img, height img) in 13 + Notty.Render.to_buffer buf Notty.Cap.dumb (0, 0) (w, h) img; 14 + Buffer.contents buf 15 + ;; 16 + let render_and_print node= 17 + render_commit_content node 18 + |>List.iter (fun img->print_endline (image_to_string img)); 19 + 20 + ;; 21 + 22 + (** Create a test node with specified prefix/rest values *) 23 + let make_test_node 24 + ?(working_copy = false) 25 + ?(immutable = false) 26 + ?(wip = false) 27 + ?(empty = false) 28 + ?(is_preview = false) 29 + ?(bookmarks = []) 30 + ~change_id_prefix 31 + ~change_id_rest 32 + ~commit_id_prefix 33 + ~commit_id_rest 34 + ~description 35 + () 36 + = 37 + Render_jj_graph. 38 + { 39 + parents = [] 40 + ; creation_time = Int64.zero 41 + ; working_copy 42 + ; immutable 43 + ; wip 44 + ; change_id = change_id_prefix ^ change_id_rest 45 + ; commit_id = commit_id_prefix ^ commit_id_rest 46 + ; description 47 + ; bookmarks 48 + ; author_email = "test@example.com" 49 + ; author_timestamp = "2024-01-01" 50 + ; empty 51 + ; hidden = false 52 + ; divergent = false 53 + ; is_preview 54 + ; change_id_prefix 55 + ; change_id_rest 56 + ; commit_id_prefix 57 + ; commit_id_rest 58 + } 59 + ;; 60 + 61 + let%expect_test "render_simple_commit" = 62 + let node = 63 + make_test_node 64 + ~change_id_prefix:"abc" 65 + ~change_id_rest:"def123" 66 + ~commit_id_prefix:"123" 67 + ~commit_id_rest:"456789" 68 + ~description:"Test commit" 69 + () 70 + in 71 + render_and_print node; 72 + [%expect 73 + {| 74 + abcdef123 test@example.com 2024-01-01 123456789 75 + Test commit 76 + |}] 77 + ;; 78 + 79 + let%expect_test "render_commit_with_bookmarks" = 80 + let node = 81 + make_test_node 82 + ~change_id_prefix:"abc" 83 + ~change_id_rest:"def" 84 + ~commit_id_prefix:"111" 85 + ~commit_id_rest:"222" 86 + ~description:"With bookmarks" 87 + ~bookmarks:[ "main"; "feature" ] 88 + () 89 + in 90 + render_and_print node; 91 + [%expect 92 + {| 93 + abcdef test@example.com 2024-01-01 main feature 111222 94 + With bookmarks 95 + |}] 96 + ;; 97 + 98 + let%expect_test "render_empty_commit" = 99 + let node = 100 + make_test_node 101 + ~change_id_prefix:"xyz" 102 + ~change_id_rest:"123" 103 + ~commit_id_prefix:"aaa" 104 + ~commit_id_rest:"bbb" 105 + ~description:"Empty commit" 106 + ~empty:true 107 + () 108 + in 109 + let img = render_commit_content node in 110 + img|>List.iter (fun img->print_endline (image_to_string img)); 111 + [%expect 112 + {| 113 + xyz123 test@example.com 2024-01-01 aaabbb 114 + (empty) Empty commit 115 + |}] 116 + ;; 117 + 118 + let%expect_test "render_working_copy_commit" = 119 + let node = 120 + make_test_node 121 + ~change_id_prefix:"wor" 122 + ~change_id_rest:"king" 123 + ~commit_id_prefix:"ccc" 124 + ~commit_id_rest:"ddd" 125 + ~description:"Working copy" 126 + ~working_copy:true 127 + () 128 + in 129 + render_and_print node; 130 + [%expect 131 + {| 132 + working test@example.com 2024-01-01 cccddd 133 + Working copy 134 + |}] 135 + ;; 136 + 137 + let%expect_test "render_commit_no_rest" = 138 + let node = 139 + make_test_node 140 + ~change_id_prefix:"short" 141 + ~change_id_rest:"" 142 + ~commit_id_prefix:"min" 143 + ~commit_id_rest:"" 144 + ~description:"Short IDs" 145 + () 146 + in 147 + render_and_print node; 148 + [%expect 149 + {| 150 + short test@example.com 2024-01-01 min 151 + Short IDs 152 + |}] 153 + ;; 154 + 155 + let%expect_test "render_multiline_description" = 156 + let node = 157 + make_test_node 158 + ~change_id_prefix:"mul" 159 + ~change_id_rest:"tiline" 160 + ~commit_id_prefix:"abc" 161 + ~commit_id_rest:"def" 162 + ~description:"First line\nSecond line\nThird line" 163 + () 164 + in 165 + render_and_print node; 166 + [%expect 167 + {| 168 + multiline test@example.com 2024-01-01 abcdef 169 + First line 170 + |}] 171 + ;; 172 + 173 + let%expect_test "render_no_description" = 174 + let node = 175 + make_test_node 176 + ~change_id_prefix:"nod" 177 + ~change_id_rest:"esc" 178 + ~commit_id_prefix:"111" 179 + ~commit_id_rest:"222" 180 + ~description:"" 181 + () 182 + in 183 + render_and_print node; 184 + [%expect 185 + {| 186 + nodesc test@example.com 2024-01-01 111222 187 + (no description set) 188 + |}] 189 + ;;
+4 -2
jj_tui/lib/config.ml
··· 3 3 type t = { 4 4 key_map : Key_map.key_config [@updater] 5 5 ; single_pane_width_threshold : int 6 - ; max_commits: int 6 + ; max_commits : int 7 7 } 8 8 [@@deriving yaml, record_updater ~derive:yaml] 9 9 10 - let default_config : t = { key_map = Key_map.default; single_pane_width_threshold = 100; max_commits= 100} 10 + let default_config : t = 11 + { key_map = Key_map.default; single_pane_width_threshold = 100; max_commits = 100 } 12 + ;; 11 13 12 14 let get_config_dir () = 13 15 let os = Os.poll_os () in
+28 -4
jj_tui/lib/jj_json.ml
··· 29 29 ; empty : bool 30 30 ; bookmarks : string list 31 31 ; author : jj_author 32 + ; change_id_prefix : string 33 + ; change_id_rest : string 34 + ; commit_id_prefix : string 35 + ; commit_id_rest : string 32 36 } 33 37 [@@deriving yojson] 34 38 ··· 46 50 ++ ',"divergent":' ++ json(divergent) 47 51 ++ ',"empty":' ++ json(empty) 48 52 ++ ',"bookmarks":[' ++ bookmarks.map(|b| json(b.name())).join(",") ++ ']' 49 - ++ ',"author":{"email":' ++ json(author.email()) ++ ',"timestamp":' ++ json(author.timestamp()) ++ '}' 53 + ++ ',"author":{"email":' ++ json(author.email().local()) ++ ',"timestamp":' ++ json(author.timestamp().local().format("%Y-%m-%d %H:%M:%S")) ++ '}' 54 + ++ ',"change_id_prefix":' ++ json(change_id.shortest(8).prefix()) 55 + ++ ',"change_id_rest":' ++ json(change_id.shortest(8).rest()) 56 + ++ ',"commit_id_prefix":' ++ json(commit_id.shortest(8).prefix()) 57 + ++ ',"commit_id_rest":' ++ json(commit_id.shortest(8).rest()) 50 58 ++ '} 51 59 '|} 52 60 ;; 53 61 54 - (** Parse JSONL (one JSON object per line) from jj log output *) 62 + (** Parse JSONL (one JSON object per line) from jj log output. 63 + When graph is included, trim all content before the first '{' on each line 64 + and skip lines without '{' (graph-only lines). *) 55 65 let parse_jj_log_output (input : string) : (jj_commit list, string) result = 56 66 try 57 67 let lines = ··· 59 69 in 60 70 let commits = 61 71 lines 62 - |> List.map (fun line -> 63 - let json = Yojson.Safe.from_string line in 72 + |> List.filter_map (fun line -> 73 + (* Find the first '{' to skip graph characters *) 74 + match String.index_opt line '{' with 75 + | None -> 76 + (* No JSON on this line, skip it (e.g., graph-only lines) *) 77 + None 78 + | Some idx -> 79 + (* Extract JSON from first '{' to end of line *) 80 + let json_str = String.sub line idx (String.length line - idx) in 81 + Some json_str) 82 + |> List.map (fun json_str -> 83 + let json = Yojson.Safe.from_string json_str in 64 84 match jj_commit_of_yojson json with 65 85 | Ok commit -> 66 86 commit ··· 109 129 ; hidden = jj_commit.hidden 110 130 ; divergent = jj_commit.divergent 111 131 ; is_preview = false 132 + ; change_id_prefix = jj_commit.change_id_prefix 133 + ; change_id_rest = jj_commit.change_id_rest 134 + ; commit_id_prefix = jj_commit.commit_id_prefix 135 + ; commit_id_rest = jj_commit.commit_id_rest 112 136 } 113 137 in 114 138 Hashtbl.add node_tbl jj_commit.commit_id n);
+20 -20
jj_tui/lib/jj_json_tests.ml
··· 2 2 3 3 let%expect_test "parse_valid_jsonl" = 4 4 let input = 5 - {|{"commit_id":"abc123","parents":[],"change_id":"xyz","description":"First commit","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 6 - {"commit_id":"def456","parents":["abc123"],"change_id":"uvw","description":"Second commit","working_copy":true,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":["main"],"author":{"email":"test@example.com","timestamp":"2024-01-02"}}|} 5 + {|{"commit_id":"abc123","parents":[],"change_id":"xyz","description":"First commit","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"xy","change_id_rest":"z","commit_id_prefix":"abc","commit_id_rest":"123"} 6 + {"commit_id":"def456","parents":["abc123"],"change_id":"uvw","description":"Second commit","working_copy":true,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":["main"],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"uv","change_id_rest":"w","commit_id_prefix":"def","commit_id_rest":"456"}|} 7 7 in 8 8 (match parse_jj_log_output input with 9 9 | Ok commits -> ··· 27 27 28 28 let%expect_test "parse_root_commit" = 29 29 let input = 30 - {|{"commit_id":"root","parents":[],"change_id":"xyz","description":"Root","working_copy":false,"immutable":true,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 30 + {|{"commit_id":"root","parents":[],"change_id":"xyz","description":"Root","working_copy":false,"immutable":true,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"xy","change_id_rest":"z","commit_id_prefix":"ro","commit_id_rest":"ot"}|} 31 31 in 32 32 (match parse_jj_log_output input with 33 33 | Ok commits -> ··· 43 43 44 44 let%expect_test "commits_to_nodes_parent_linking" = 45 45 let input = 46 - {|{"commit_id":"parent","parents":[],"change_id":"p","description":"Parent","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 47 - {"commit_id":"child","parents":["parent"],"change_id":"c","description":"Child","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}}|} 46 + {|{"commit_id":"parent","parents":[],"change_id":"p","description":"Parent","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"p","change_id_rest":"","commit_id_prefix":"par","commit_id_rest":"ent"} 47 + {"commit_id":"child","parents":["parent"],"change_id":"c","description":"Child","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"c","change_id_rest":"","commit_id_prefix":"chi","commit_id_rest":"ld"}|} 48 48 in 49 49 (match parse_jj_log_output input with 50 50 | Ok commits -> ··· 70 70 71 71 let%expect_test "parse_multiple_parents" = 72 72 let input = 73 - {|{"commit_id":"parent1","parents":[],"change_id":"p1","description":"Parent 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 74 - {"commit_id":"parent2","parents":[],"change_id":"p2","description":"Parent 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 75 - {"commit_id":"merge","parents":["parent1","parent2"],"change_id":"m","description":"Merge commit","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 73 + {|{"commit_id":"parent1","parents":[],"change_id":"p1","description":"Parent 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"p","change_id_rest":"1","commit_id_prefix":"par","commit_id_rest":"ent1"} 74 + {"commit_id":"parent2","parents":[],"change_id":"p2","description":"Parent 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"p","change_id_rest":"2","commit_id_prefix":"par","commit_id_rest":"ent2"} 75 + {"commit_id":"merge","parents":["parent1","parent2"],"change_id":"m","description":"Merge commit","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"},"change_id_prefix":"m","change_id_rest":"","commit_id_prefix":"mer","commit_id_rest":"ge"}|} 76 76 in 77 77 (match parse_jj_log_output input with 78 78 | Ok commits -> ··· 100 100 101 101 let%expect_test "parse_commit_with_bookmarks" = 102 102 let input = 103 - {|{"commit_id":"abc123","parents":[],"change_id":"xyz","description":"Commit with bookmarks","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":["main","feature"],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 103 + {|{"commit_id":"abc123","parents":[],"change_id":"xyz","description":"Commit with bookmarks","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":["main","feature"],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"xy","change_id_rest":"z","commit_id_prefix":"abc","commit_id_rest":"123"}|} 104 104 in 105 105 (match parse_jj_log_output input with 106 106 | Ok commits -> ··· 119 119 120 120 let%expect_test "parse_wip_commit" = 121 121 let input = 122 - {|{"commit_id":"wip123","parents":[],"change_id":"xyz","description":"wip: work in progress","working_copy":true,"immutable":false,"wip":true,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 122 + {|{"commit_id":"wip123","parents":[],"change_id":"xyz","description":"wip: work in progress","working_copy":true,"immutable":false,"wip":true,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"xy","change_id_rest":"z","commit_id_prefix":"wip","commit_id_rest":"123"}|} 123 123 in 124 124 (match parse_jj_log_output input with 125 125 | Ok commits -> ··· 165 165 166 166 let%expect_test "commits_to_nodes_preserves_order" = 167 167 let input = 168 - {|{"commit_id":"first","parents":[],"change_id":"f","description":"First","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}} 169 - {"commit_id":"second","parents":["first"],"change_id":"s","description":"Second","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 170 - {"commit_id":"third","parents":["second"],"change_id":"t","description":"Third","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 168 + {|{"commit_id":"first","parents":[],"change_id":"f","description":"First","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"f","change_id_rest":"","commit_id_prefix":"fir","commit_id_rest":"st"} 169 + {"commit_id":"second","parents":["first"],"change_id":"s","description":"Second","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"s","change_id_rest":"","commit_id_prefix":"sec","commit_id_rest":"ond"} 170 + {"commit_id":"third","parents":["second"],"change_id":"t","description":"Third","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"},"change_id_prefix":"t","change_id_rest":"","commit_id_prefix":"thi","commit_id_rest":"rd"}|} 171 171 in 172 172 (match parse_jj_log_output input with 173 173 | Ok commits -> ··· 186 186 187 187 let%expect_test "commits_to_nodes_copies_fields" = 188 188 let input = 189 - {|{"commit_id":"test","parents":[],"change_id":"xyz","description":"Test commit","working_copy":true,"immutable":true,"wip":true,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"}}|} 189 + {|{"commit_id":"test","parents":[],"change_id":"xyz","description":"Test commit","working_copy":true,"immutable":true,"wip":true,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-01"},"change_id_prefix":"xy","change_id_rest":"z","commit_id_prefix":"te","commit_id_rest":"st"}|} 190 190 in 191 191 (match parse_jj_log_output input with 192 192 | Ok commits -> ··· 210 210 211 211 let%expect_test "commits_to_nodes_missing_parent_creates_elided" = 212 212 let input = 213 - {|{"commit_id":"child","parents":["missing_parent"],"change_id":"c","description":"Child with missing parent","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}}|} 213 + {|{"commit_id":"child","parents":["missing_parent"],"change_id":"c","description":"Child with missing parent","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"c","change_id_rest":"","commit_id_prefix":"chi","commit_id_rest":"ld"}|} 214 214 in 215 215 (match parse_jj_log_output input with 216 216 | Ok commits -> ··· 237 237 238 238 let%expect_test "commits_to_nodes_multiple_children_same_missing_parent" = 239 239 let input = 240 - {|{"commit_id":"child1","parents":["missing_parent"],"change_id":"c1","description":"Child 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 241 - {"commit_id":"child2","parents":["missing_parent"],"change_id":"c2","description":"Child 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 240 + {|{"commit_id":"child1","parents":["missing_parent"],"change_id":"c1","description":"Child 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"c","change_id_rest":"1","commit_id_prefix":"chi","commit_id_rest":"ld1"} 241 + {"commit_id":"child2","parents":["missing_parent"],"change_id":"c2","description":"Child 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"},"change_id_prefix":"c","change_id_rest":"2","commit_id_prefix":"chi","commit_id_rest":"ld2"}|} 242 242 in 243 243 (match parse_jj_log_output input with 244 244 | Ok commits -> ··· 262 262 |}] 263 263 ;; 264 264 265 - let%expect_test "commits_to_nodes_multiple_children_same_missing_parent" = 265 + let%expect_test "commits_to_nodes_same_missing_parent_physical_equality" = 266 266 let input = 267 - {|{"commit_id":"child1","parents":["missing_parent"],"change_id":"c1","description":"Child 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"}} 268 - {"commit_id":"child2","parents":["missing_parent"],"change_id":"c2","description":"Child 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"}}|} 267 + {|{"commit_id":"child1","parents":["missing_parent"],"change_id":"c1","description":"Child 1","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-02"},"change_id_prefix":"c","change_id_rest":"1","commit_id_prefix":"chi","commit_id_rest":"ld1"} 268 + {"commit_id":"child2","parents":["missing_parent"],"change_id":"c2","description":"Child 2","working_copy":false,"immutable":false,"wip":false,"hidden":false,"divergent":false,"empty":false,"bookmarks":[],"author":{"email":"test@example.com","timestamp":"2024-01-03"},"change_id_prefix":"c","change_id_rest":"2","commit_id_prefix":"chi","commit_id_rest":"ld2"}|} 269 269 in 270 270 (match parse_jj_log_output input with 271 271 | Ok commits ->
+53 -46
jj_tui/lib/key.ml
··· 1 - type modifier = [ `Meta | `Shift | `Ctrl ] 1 + type modifier = 2 + [ `Meta 3 + | `Shift 4 + | `Ctrl 5 + ] 2 6 3 7 type t = { 4 - key: char; 5 - modifiers: modifier list; 8 + key : char 9 + ; modifiers : modifier list 6 10 } 7 11 8 12 let sort_and_dedup_modifiers mods = 9 - let modifier_order = function 10 - | `Shift -> 0 11 - | `Meta -> 1 12 - | `Ctrl -> 2 13 - in 14 - mods 15 - |> List.sort_uniq (fun a b -> compare (modifier_order a) (modifier_order b)) 13 + let modifier_order = function `Shift -> 0 | `Meta -> 1 | `Ctrl -> 2 in 14 + mods |> List.sort_uniq (fun a b -> compare (modifier_order a) (modifier_order b)) 15 + ;; 16 16 17 17 let key_of_string str = 18 18 let parts = String.split_on_char '+' str in 19 19 let rec process_parts mods = function 20 - | [] -> Error "No key character provided" 21 - | [k] when String.length k = 1 -> 22 - let key = k.[0] in 23 - Ok { key = key; modifiers = sort_and_dedup_modifiers ( mods) } 20 + | [] -> 21 + Error "No key character provided" 22 + | [ k ] when String.length k = 1 -> 23 + let key = k.[0] in 24 + Ok { key; modifiers = sort_and_dedup_modifiers mods } 24 25 | mod_str :: rest -> 25 - let modifier = match String.uppercase_ascii mod_str with 26 - | "C" | "CTRL" -> Ok `Ctrl 27 - | "S" | "SHIFT" -> Ok `Shift 28 - | "A" | "ALT" -> Ok `Meta 29 - | other -> Error (Printf.sprintf "Unknown modifier: %s" other) 26 + let modifier = 27 + match String.uppercase_ascii mod_str with 28 + | "C" | "CTRL" -> 29 + Ok `Ctrl 30 + | "S" | "SHIFT" -> 31 + Ok `Shift 32 + | "A" | "ALT" -> 33 + Ok `Meta 34 + | other -> 35 + Error (Printf.sprintf "Unknown modifier: %s" other) 30 36 in 31 - match modifier with 32 - | Ok m -> process_parts (m :: mods) rest 33 - | Error e -> Error e 37 + (match modifier with Ok m -> process_parts (m :: mods) rest | Error e -> Error e) 34 38 in 35 39 process_parts [] parts 40 + ;; 36 41 37 - let key_of_string_exn str= match key_of_string str with Ok k -> k | Error msg -> failwith ("Invalid key: " ^ msg) 42 + let key_of_string_exn str = 43 + match key_of_string str with Ok k -> k | Error msg -> failwith ("Invalid key: " ^ msg) 44 + ;; 38 45 39 46 let key_to_string { key; modifiers } = 40 47 let modifier_str = 41 48 modifiers 42 - |> List.map (function 43 - | `Shift -> "S" 44 - | `Meta -> "A" 45 - | `Ctrl -> "C") 49 + |> List.map (function `Shift -> "S" | `Meta -> "A" | `Ctrl -> "C") 46 50 |> String.concat "+" 47 51 in 48 - if modifier_str = "" then 49 - String.make 1 key 50 - else 51 - modifier_str ^ "+" ^ (String.make 1 key) 52 + if modifier_str = "" then String.make 1 key else modifier_str ^ "+" ^ String.make 1 key 53 + ;; 52 54 53 55 let key_of_yaml = function 54 56 | `String s -> 55 57 (match key_of_string s with 56 - | Ok k -> Ok k 57 - | Error msg -> Error (`Msg("Invalid key format: " ^ msg))) 58 - | _ -> Error (`Msg "Expected string for key") 58 + | Ok k -> 59 + Ok k 60 + | Error msg -> 61 + Error (`Msg ("Invalid key format: " ^ msg))) 62 + | _ -> 63 + Error (`Msg "Expected string for key") 64 + ;; 59 65 60 - let key_to_yaml k = 61 - `String (key_to_string k) 62 - 66 + let key_to_yaml k = `String (key_to_string k) 63 67 let pp fmt k = Format.fprintf fmt "%s" (key_to_string k) 64 - 65 - let equal k1 k2 = k1.key = k2.key && k1.modifiers = k2.modifiers 68 + let equal k1 k2 = k1.key = k2.key && k1.modifiers = k2.modifiers 66 69 67 - let hash k = Char.hash k.key + List.fold_left (fun acc m -> acc * 31 + match m with 68 - | `Meta -> 1 69 - | `Shift -> 2 70 - | `Ctrl -> 3 71 - ) 0 k.modifiers 70 + let hash k = 71 + Char.hash k.key 72 + + List.fold_left 73 + (fun acc m -> (acc * 31) + match m with `Meta -> 1 | `Shift -> 2 | `Ctrl -> 3) 74 + 0 75 + k.modifiers 76 + ;; 72 77 73 78 let compare k1 k2 = 74 79 match compare k1.key k2.key with 75 - | 0 -> List.compare compare k1.modifiers k2.modifiers 76 - | c -> c 80 + | 0 -> 81 + List.compare compare k1.modifiers k2.modifiers 82 + | c -> 83 + c 77 84 ;;
+1 -5
jj_tui/lib/logging.ml
··· 70 70 in 71 71 let with_stamp h tags k ppf fmt = 72 72 let stamp = 73 - match tags with 74 - | None -> 75 - None 76 - | Some tags -> 77 - Logs.Tag.find timestamp_tag tags 73 + match tags with None -> None | Some tags -> Logs.Tag.find timestamp_tag tags 78 74 in 79 75 let dt = Format.pp_print_option (Logs.Tag.printer timestamp_tag) in 80 76 Format.kfprintf
+6 -6
jj_tui/lib/os.ml
··· 1 1 module Internal = struct 2 - let normalise_os raw = 3 - match String.lowercase_ascii raw with "darwin" | "osx" -> "macos" | s -> s 4 - ;; 2 + let normalise_os raw = 3 + match String.lowercase_ascii raw with "darwin" | "osx" -> "macos" | s -> s 4 + ;; 5 5 end 6 6 7 7 let poll_os () = ··· 9 9 match Sys.os_type with 10 10 | "Unix" -> 11 11 (try 12 - let uname_in = Unix.open_process_args_in "uname" [| "uname"; "-s" |] in 12 + let uname_in = Unix.open_process_args_in "uname" [| "uname"; "-s" |] in 13 13 let str = uname_in |> In_channel.input_all in 14 - Unix.wait()|>ignore; 14 + Unix.wait () |> ignore; 15 15 Some (str |> String.lowercase_ascii |> String.trim) 16 16 with 17 17 | _ -> ··· 20 20 Some (s |> String.lowercase_ascii |> String.trim) 21 21 in 22 22 match raw with None | Some "" -> None | Some s -> Some (Internal.normalise_os s) 23 - ;; 23 + ;;
+2 -1
jj_tui/lib/outputParsing.ml
··· 135 135 (fst |> String.concat ";") ^ "][" ^ (snd |> String.concat ";") |> print_endline 136 136 | Error e -> 137 137 print_endline e); 138 - [%expect {| 138 + [%expect 139 + {| 139 140 hiii 140 141 ][heyyyy 141 142 |}]
+2 -2
jj_tui/lib/process.ml
··· 1 1 type rev_id = { 2 2 change_id : string 3 3 ; commit_id : string 4 - 5 - ; divergent : bool(** Indicates the changeid is conflicted and we must use the commitid *) 4 + ; divergent : bool 5 + (** Indicates the changeid is conflicted and we must use the commitid *) 6 6 } 7 7 8 8 type 'a maybe_unique =
+4 -9
jj_tui/lib/process_wrappers.ml
··· 174 174 graph, revs |> Array.of_list 175 175 ;; 176 176 177 - (** Fetch graph data as JSON and parse into commits *) 177 + (** Fetch graph data as JSON and parse into commits. 178 + Uses graph output (not --no-graph) because the graph ensures nodes are 179 + in the correct topological order for rendering. *) 178 180 let get_graph_json ?revset limit = 179 181 let args = 180 - [ 181 - "log" 182 - ; "--no-graph" 183 - ; "-T" 184 - ; Jj_json.json_log_template 185 - ; "--limit" 186 - ; string_of_int limit 187 - ] 182 + [ "log"; "-T"; Jj_json.json_log_template; "--limit"; string_of_int limit ] 188 183 in 189 184 let args = match revset with Some r -> args @ [ "-r"; r ] | None -> args in 190 185 let output = jj_no_log args ~color:false in
+116 -20
jj_tui/lib/render_jj_graph.ml
··· 53 53 ; hidden : bool 54 54 ; divergent : bool 55 55 ; is_preview : bool 56 + ; change_id_prefix : string 57 + ; change_id_rest : string 58 + ; commit_id_prefix : string 59 + ; commit_id_rest : string 56 60 } 57 61 58 62 (** Special marker for elided nodes *) ··· 76 80 ; hidden = true 77 81 ; divergent = false 78 82 ; is_preview = false 83 + ; change_id_prefix = "" 84 + ; change_id_rest = "" 85 + ; commit_id_prefix = "" 86 + ; commit_id_rest = "" 79 87 } 80 88 ;; 81 89 ··· 111 119 ; hidden = false 112 120 ; divergent = false 113 121 ; is_preview = true 122 + ; change_id_prefix = "" 123 + ; change_id_rest = "" 124 + ; commit_id_prefix = "" 125 + ; commit_id_rest = "" 114 126 } 115 127 ;; 116 128 ··· 158 170 (** Structured output for UI integration *) 159 171 type graph_row_output = { 160 172 graph_chars : string (** The graph prefix like "○ " or "├─╮" *) 173 + ; graph_image : Notty.image (** Notty image for graph prefix, with styling *) 161 174 ; node : node (** The node this row represents *) 162 175 ; row_type : row_type (** What kind of row this is *) 163 176 } ··· 884 897 else PadRow 885 898 ;; 886 899 900 + (** Trim trailing whitespace from a graph image to match its string form. *) 901 + let trim_graph_image ~graph_chars (img : Notty.image) : Notty.image = 902 + let open Notty in 903 + let trimmed_width = I.width (I.string A.empty graph_chars) in 904 + let width = I.width img in 905 + if width > trimmed_width then I.hcrop 0 (width - trimmed_width) img else img 906 + ;; 907 + 887 908 (** Render nodes to structured output for UI integration *) 888 909 let render_nodes_structured 889 910 ?(info_lines = fun _ -> 0) 911 + ?(node_attr = fun _ -> Notty.A.empty) 890 912 (_state : state) 891 913 (nodes : node list) : graph_row_output list 892 914 = ··· 897 919 (fun n -> 898 920 let row = next_row ~columns n in 899 921 (match !extra_pad_line_ref with 900 - | Some s -> 922 + | Some (s, img) -> 901 923 let trimmed = String.trim s in 924 + let trimmed_img = trim_graph_image ~graph_chars:trimmed img in 902 925 result 903 - := { graph_chars = trimmed; node = n; row_type = classify_row_type trimmed } 926 + := { 927 + graph_chars = trimmed 928 + ; graph_image = trimmed_img 929 + ; node = n 930 + ; row_type = classify_row_type trimmed 931 + } 904 932 :: !result; 905 933 extra_pad_line_ref := None 906 934 | None -> 907 935 ()); 908 936 let node_buf = Buffer.create 64 in 937 + let node_images = ref [] in 909 938 Array.iter 910 939 (fun entry -> 911 940 match entry with 912 941 | NL_Node -> 913 942 Buffer.add_utf_8_uchar node_buf row.glyph; 914 - Buffer.add_char node_buf ' ' 943 + Buffer.add_char node_buf ' '; 944 + let glyph_img = Notty.I.uchar (node_attr row.row_node) row.glyph 1 1 in 945 + let space_img = Notty.I.string Notty.A.empty " " in 946 + node_images := Notty.I.hcat [ glyph_img; space_img ] :: !node_images 915 947 | NL_Parent -> 916 - Buffer.add_string node_buf glyphs.(Glyph.parent) 948 + Buffer.add_string node_buf glyphs.(Glyph.parent); 949 + node_images 950 + := Notty.I.string Notty.A.empty glyphs.(Glyph.parent) :: !node_images 917 951 | NL_Ancestor -> 918 - Buffer.add_string node_buf glyphs.(Glyph.ancestor) 952 + Buffer.add_string node_buf glyphs.(Glyph.ancestor); 953 + node_images 954 + := Notty.I.string Notty.A.empty glyphs.(Glyph.ancestor) :: !node_images 919 955 | NL_Blank -> 920 - Buffer.add_string node_buf glyphs.(Glyph.space)) 956 + Buffer.add_string node_buf glyphs.(Glyph.space); 957 + node_images 958 + := Notty.I.string Notty.A.empty glyphs.(Glyph.space) :: !node_images) 921 959 row.node_line; 922 960 let node_str = Buffer.contents node_buf |> String.trim in 961 + let node_img = !node_images |> List.rev |> Notty.I.hcat in 962 + let node_img = trim_graph_image ~graph_chars:node_str node_img in 923 963 result 924 - := { graph_chars = node_str; node = n; row_type = classify_row_type node_str } 964 + := { 965 + graph_chars = node_str 966 + ; graph_image = node_img 967 + ; node = n 968 + ; row_type = classify_row_type node_str 969 + } 925 970 :: !result; 926 971 (match row.link_line with 927 972 | Some link_row -> 928 973 let link_buf = Buffer.create 64 in 974 + let link_images = ref [] in 929 975 Array.iter 930 976 (fun cur -> 931 977 let glyph_idx = select_link_glyph cur ~merge:row.merge in 932 - Buffer.add_string link_buf glyphs.(glyph_idx)) 978 + Buffer.add_string link_buf glyphs.(glyph_idx); 979 + link_images 980 + := Notty.I.string Notty.A.empty glyphs.(glyph_idx) :: !link_images) 933 981 link_row; 934 982 let link_str = Buffer.contents link_buf |> String.trim in 983 + let link_img = !link_images |> List.rev |> Notty.I.hcat in 984 + let link_img = trim_graph_image ~graph_chars:link_str link_img in 935 985 result 936 - := { graph_chars = link_str; node = n; row_type = classify_row_type link_str } 986 + := { 987 + graph_chars = link_str 988 + ; graph_image = link_img 989 + ; node = n 990 + ; row_type = classify_row_type link_str 991 + } 937 992 :: !result 938 993 | None -> 939 994 ()); ··· 941 996 (match row.term_line with 942 997 | Some term_row -> 943 998 let term_buf1 = Buffer.create 64 in 999 + let term_images1 = ref [] in 944 1000 Array.iteri 945 1001 (fun i term -> 946 1002 if term 947 - then Buffer.add_string term_buf1 glyphs.(Glyph.parent) 1003 + then ( 1004 + Buffer.add_string term_buf1 glyphs.(Glyph.parent); 1005 + term_images1 1006 + := Notty.I.string Notty.A.empty glyphs.(Glyph.parent) :: !term_images1) 948 1007 else ( 949 1008 let pad_glyph = pad_line_to_glyph row.pad_lines.(i) in 950 - Buffer.add_string term_buf1 glyphs.(pad_glyph))) 1009 + Buffer.add_string term_buf1 glyphs.(pad_glyph); 1010 + term_images1 1011 + := Notty.I.string Notty.A.empty glyphs.(pad_glyph) :: !term_images1)) 951 1012 term_row; 952 1013 let term_str1 = Buffer.contents term_buf1 |> String.trim in 1014 + let term_img1 = !term_images1 |> List.rev |> Notty.I.hcat in 1015 + let term_img1 = trim_graph_image ~graph_chars:term_str1 term_img1 in 953 1016 result 954 - := { graph_chars = term_str1; node = n; row_type = classify_row_type term_str1 } 1017 + := { 1018 + graph_chars = term_str1 1019 + ; graph_image = term_img1 1020 + ; node = n 1021 + ; row_type = classify_row_type term_str1 1022 + } 955 1023 :: !result; 956 1024 let term_buf2 = Buffer.create 64 in 1025 + let term_images2 = ref [] in 957 1026 Array.iteri 958 1027 (fun i term -> 959 1028 if term 960 - then Buffer.add_string term_buf2 glyphs.(Glyph.termination) 1029 + then ( 1030 + Buffer.add_string term_buf2 glyphs.(Glyph.termination); 1031 + term_images2 1032 + := Notty.I.string Notty.A.empty glyphs.(Glyph.termination) :: !term_images2) 961 1033 else ( 962 1034 let pad_glyph = pad_line_to_glyph row.pad_lines.(i) in 963 - Buffer.add_string term_buf2 glyphs.(pad_glyph))) 1035 + Buffer.add_string term_buf2 glyphs.(pad_glyph); 1036 + term_images2 1037 + := Notty.I.string Notty.A.empty glyphs.(pad_glyph) :: !term_images2)) 964 1038 term_row; 965 1039 let term_str2 = Buffer.contents term_buf2 |> String.trim in 1040 + let term_img2 = !term_images2 |> List.rev |> Notty.I.hcat in 1041 + let term_img2 = trim_graph_image ~graph_chars:term_str2 term_img2 in 966 1042 result 967 - := { graph_chars = term_str2; node = n; row_type = classify_row_type term_str2 } 1043 + := { 1044 + graph_chars = term_str2 1045 + ; graph_image = term_img2 1046 + ; node = n 1047 + ; row_type = classify_row_type term_str2 1048 + } 968 1049 :: !result; 969 1050 need_extra_pad := true 970 1051 | None -> 971 1052 ()); 972 1053 let pad_buf = Buffer.create 64 in 1054 + let pad_images = ref [] in 973 1055 Array.iter 974 1056 (fun entry -> 975 1057 let glyph_idx = pad_line_to_glyph entry in 976 - Buffer.add_string pad_buf glyphs.(glyph_idx)) 1058 + Buffer.add_string pad_buf glyphs.(glyph_idx); 1059 + pad_images := Notty.I.string Notty.A.empty glyphs.(glyph_idx) :: !pad_images) 977 1060 row.pad_lines; 978 1061 let base_pad_line = Buffer.contents pad_buf in 979 - if !need_extra_pad then extra_pad_line_ref := Some base_pad_line; 1062 + let base_pad_img = !pad_images |> List.rev |> Notty.I.hcat in 1063 + if !need_extra_pad then extra_pad_line_ref := Some (base_pad_line, base_pad_img); 980 1064 let extra_rows = info_lines n in 981 1065 for _ = 1 to extra_rows do 982 1066 let info_pad_buf = Buffer.create 64 in 1067 + let info_pad_images = ref [] in 983 1068 Array.iter 984 1069 (fun col -> 985 1070 let glyph_idx = pad_line_to_glyph (column_to_pad_line col) in 986 - Buffer.add_string info_pad_buf glyphs.(glyph_idx)) 1071 + Buffer.add_string info_pad_buf glyphs.(glyph_idx); 1072 + info_pad_images 1073 + := Notty.I.string Notty.A.empty glyphs.(glyph_idx) :: !info_pad_images) 987 1074 !columns; 988 1075 let info_pad_str = Buffer.contents info_pad_buf |> String.trim in 1076 + let info_pad_img = !info_pad_images |> List.rev |> Notty.I.hcat in 1077 + let info_pad_img = trim_graph_image ~graph_chars:info_pad_str info_pad_img in 989 1078 result 990 1079 := { 991 1080 graph_chars = info_pad_str 1081 + ; graph_image = info_pad_img 992 1082 ; node = n 993 1083 ; row_type = classify_row_type info_pad_str 994 1084 } ··· 996 1086 done) 997 1087 nodes; 998 1088 (match !extra_pad_line_ref with 999 - | Some s -> 1089 + | Some (s, img) -> 1000 1090 let trimmed = String.trim s in 1091 + let trimmed_img = trim_graph_image ~graph_chars:trimmed img in 1001 1092 let last_node = List.hd (List.rev nodes) in 1002 1093 result 1003 - := { graph_chars = trimmed; node = last_node; row_type = classify_row_type trimmed } 1094 + := { 1095 + graph_chars = trimmed 1096 + ; graph_image = trimmed_img 1097 + ; node = last_node 1098 + ; row_type = classify_row_type trimmed 1099 + } 1004 1100 :: !result 1005 1101 | None -> 1006 1102 ());
+88
jj_tui/lib/render_jj_graph_tests.ml
··· 21 21 ; hidden = false 22 22 ; divergent = false 23 23 ; is_preview = false 24 + ; change_id_prefix = "" 25 + ; change_id_rest = "" 26 + ; commit_id_prefix = "" 27 + ; commit_id_rest = "" 24 28 } 25 29 in 26 30 let b : node = ··· 40 44 ; hidden = false 41 45 ; divergent = false 42 46 ; is_preview = false 47 + ; change_id_prefix = "" 48 + ; change_id_rest = "" 49 + ; commit_id_prefix = "" 50 + ; commit_id_rest = "" 43 51 } 44 52 in 45 53 let c : node = ··· 59 67 ; hidden = false 60 68 ; divergent = false 61 69 ; is_preview = false 70 + ; change_id_prefix = "" 71 + ; change_id_rest = "" 72 + ; commit_id_prefix = "" 73 + ; commit_id_rest = "" 62 74 } 63 75 in 64 76 let a : node = ··· 78 90 ; hidden = false 79 91 ; divergent = false 80 92 ; is_preview = false 93 + ; change_id_prefix = "" 94 + ; change_id_rest = "" 95 + ; commit_id_prefix = "" 96 + ; commit_id_rest = "" 81 97 } 82 98 in 83 99 let state : state = { depth = 0; columns = [||]; pending_joins = [] } in ··· 122 138 ; hidden = false 123 139 ; divergent = false 124 140 ; is_preview = false 141 + ; change_id_prefix = "" 142 + ; change_id_rest = "" 143 + ; commit_id_prefix = "" 144 + ; commit_id_rest = "" 125 145 } 126 146 in 127 147 let rzmu : node = ··· 141 161 ; hidden = false 142 162 ; divergent = false 143 163 ; is_preview = false 164 + ; change_id_prefix = "" 165 + ; change_id_rest = "" 166 + ; commit_id_prefix = "" 167 + ; commit_id_rest = "" 144 168 } 145 169 in 146 170 let osynn : node = ··· 160 184 ; hidden = false 161 185 ; divergent = false 162 186 ; is_preview = false 187 + ; change_id_prefix = "" 188 + ; change_id_rest = "" 189 + ; commit_id_prefix = "" 190 + ; commit_id_rest = "" 163 191 } 164 192 in 165 193 let tzrqs : node = ··· 179 207 ; hidden = false 180 208 ; divergent = false 181 209 ; is_preview = false 210 + ; change_id_prefix = "" 211 + ; change_id_rest = "" 212 + ; commit_id_prefix = "" 213 + ; commit_id_rest = "" 182 214 } 183 215 in 184 216 let qlnop : node = ··· 198 230 ; hidden = false 199 231 ; divergent = false 200 232 ; is_preview = false 233 + ; change_id_prefix = "" 234 + ; change_id_rest = "" 235 + ; commit_id_prefix = "" 236 + ; commit_id_rest = "" 201 237 } 202 238 in 203 239 let loxn : node = ··· 217 253 ; hidden = false 218 254 ; divergent = false 219 255 ; is_preview = false 256 + ; change_id_prefix = "" 257 + ; change_id_rest = "" 258 + ; commit_id_prefix = "" 259 + ; commit_id_rest = "" 220 260 } 221 261 in 222 262 let otsz : node = ··· 236 276 ; hidden = false 237 277 ; divergent = false 238 278 ; is_preview = false 279 + ; change_id_prefix = "" 280 + ; change_id_rest = "" 281 + ; commit_id_prefix = "" 282 + ; commit_id_rest = "" 239 283 } 240 284 in 241 285 let yrsq : node = ··· 255 299 ; hidden = false 256 300 ; divergent = false 257 301 ; is_preview = false 302 + ; change_id_prefix = "" 303 + ; change_id_rest = "" 304 + ; commit_id_prefix = "" 305 + ; commit_id_rest = "" 258 306 } 259 307 in 260 308 let xysm : node = ··· 274 322 ; hidden = false 275 323 ; divergent = false 276 324 ; is_preview = false 325 + ; change_id_prefix = "" 326 + ; change_id_rest = "" 327 + ; commit_id_prefix = "" 328 + ; commit_id_rest = "" 277 329 } 278 330 in 279 331 let wwtl : node = ··· 293 345 ; hidden = false 294 346 ; divergent = false 295 347 ; is_preview = false 348 + ; change_id_prefix = "" 349 + ; change_id_rest = "" 350 + ; commit_id_prefix = "" 351 + ; commit_id_rest = "" 296 352 } 297 353 in 298 354 (* Render order matching the example top-to-bottom. *) ··· 386 442 ; hidden = false 387 443 ; divergent = false 388 444 ; is_preview = false 445 + ; change_id_prefix = "" 446 + ; change_id_rest = "" 447 + ; commit_id_prefix = "" 448 + ; commit_id_rest = "" 389 449 } 390 450 in 391 451 Hashtbl.add node_tbl jj_node.commit_id n); ··· 510 570 ; hidden = false 511 571 ; divergent = false 512 572 ; is_preview = false 573 + ; change_id_prefix = "" 574 + ; change_id_rest = "" 575 + ; commit_id_prefix = "" 576 + ; commit_id_rest = "" 513 577 } 514 578 in 515 579 Printf.printf "is_elided: %b\n" (Render_jj_graph.is_elided normal); ··· 537 601 ; hidden = false 538 602 ; divergent = false 539 603 ; is_preview = false 604 + ; change_id_prefix = "" 605 + ; change_id_rest = "" 606 + ; commit_id_prefix = "" 607 + ; commit_id_rest = "" 540 608 } 541 609 in 542 610 let child : node = ··· 602 670 ; hidden = false 603 671 ; divergent = false 604 672 ; is_preview = false 673 + ; change_id_prefix = "" 674 + ; change_id_rest = "" 675 + ; commit_id_prefix = "" 676 + ; commit_id_rest = "" 605 677 } 606 678 in 607 679 let b : node = ··· 621 693 ; hidden = false 622 694 ; divergent = false 623 695 ; is_preview = false 696 + ; change_id_prefix = "" 697 + ; change_id_rest = "" 698 + ; commit_id_prefix = "" 699 + ; commit_id_rest = "" 624 700 } 625 701 in 626 702 let c : node = ··· 640 716 ; hidden = false 641 717 ; divergent = false 642 718 ; is_preview = false 719 + ; change_id_prefix = "" 720 + ; change_id_rest = "" 721 + ; commit_id_prefix = "" 722 + ; commit_id_rest = "" 643 723 } 644 724 in 645 725 let a : node = ··· 659 739 ; hidden = false 660 740 ; divergent = false 661 741 ; is_preview = false 742 + ; change_id_prefix = "" 743 + ; change_id_rest = "" 744 + ; commit_id_prefix = "" 745 + ; commit_id_rest = "" 662 746 } 663 747 in 664 748 let state : state = { depth = 0; columns = [||]; pending_joins = [] } in ··· 721 805 ; hidden = false 722 806 ; divergent = false 723 807 ; is_preview = false 808 + ; change_id_prefix = "" 809 + ; change_id_rest = "" 810 + ; commit_id_prefix = "" 811 + ; commit_id_rest = "" 724 812 } 725 813 in 726 814 let state : state = { depth = 0; columns = [||]; pending_joins = [] } in
+13 -12
jj_tui/lib/widgets_citty.ml
··· 114 114 *) 115 115 Lwd_table.map_reduce 116 116 (fun _ x -> 117 - match control_character_index x 0 with 118 - | exception Not_found -> 119 - x, None 120 - | i -> 121 - let prefix = String.sub x 0 i in 122 - (match split_lines x [] (i + 1) with 123 - | [] -> 124 - assert false 125 - | suffix :: rest -> 126 - let ui = rest |> List.rev_map wrap_line |> Lwd_utils.reduce Ui.pack_y in 127 - prefix, Some (ui, suffix))) 117 + match control_character_index x 0 with 118 + | exception Not_found -> 119 + x, None 120 + | i -> 121 + let prefix = String.sub x 0 i in 122 + (match split_lines x [] (i + 1) with 123 + | [] -> 124 + assert false 125 + | suffix :: rest -> 126 + let ui = rest |> List.rev_map wrap_line |> Lwd_utils.reduce Ui.pack_y in 127 + prefix, Some (ui, suffix))) 128 128 ( ("", None) 129 129 , fun (pa, ta) (pb, tb) -> 130 130 match ta with ··· 140 140 | Some (ub, sb) -> 141 141 join3 ua (wrap_line line) ub, sb) ) ) 142 142 table 143 - |> (* After reducing the table, we produce the final UI, interpreting 143 + |> 144 + (* After reducing the table, we produce the final UI, interpreting 144 145 unterminated prefix and suffix has line of their own. *) 145 146 Lwd.map ~f:(function 146 147 | pa, None ->
+1 -2
jj_tui/widget-test/dune
··· 1 1 (executable 2 2 (public_name widget_test) 3 3 (name main) 4 - (libraries jj_tui lwd_picos nottui base stdio ) 5 - ) 4 + (libraries jj_tui lwd_picos nottui base stdio))
+68 -28
jj_tui/widget-test/main.ml
··· 10 10 11 11 let pString s = W.string s |> Lwd.pure 12 12 13 - let test_input= 14 - let inp_var =("hi there",5)|>Lwd.var in 15 - let inp_text= inp_var|>Lwd.get in 13 + let test_input = 14 + let inp_var = ("hi there", 5) |> Lwd.var in 15 + let inp_text = inp_var |> Lwd.get in 16 + W.edit_field inp_text ~on_change:(fun x -> Lwd.set inp_var x) ~on_submit:(fun x -> ()) 17 + ;; 16 18 17 - W.edit_field inp_text ~on_change:(fun x->Lwd.set inp_var x) ~on_submit:(fun x->()) 18 19 let w_0 = 19 20 W.hbox 20 21 [ 21 - Ui.border ~thick:2 ~style:Ui.Border.unicode ~label_top:"top" (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 22 - Ui.border ~thick:0 ~pad_w:2 ~pad_h:1 ~style:Ui.Border.unicode_double ~label_bottom:"bottom" (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 23 - Ui.border ~thick:1 ~style:Ui.Border.unicode_rounded ~label_top:"top" ~label_bottom:"bottom" (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 24 - 25 - Ui.border ~focus_attr: (A.fg A.red) ~focus_style:Ui.Border.unicode_double ~thick: 2 ~pad_w:2 ~pad_h:1 ~style:Ui.Border.unicode (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 26 - 27 - W.Box.box ~pad_w:2 ~pad_h:1 (Ui.vcat [W.string "hi this is a ui element with an\n old style border box"; W.string "hi"]|>Lwd.pure); 28 - (* pString " |" *) 29 - (* ; (let og = *) 30 - (* Ui.vcat *) 31 - (* [ *) 32 - (* W.string "123456789000000000000000000000000000000000000000000000000000end" *) 33 - (* ; W.string "123456789000000000000000000000000000000000000000000000000000end" *) 34 - (* ] *) 35 - (* in *) 36 - (* og *) 37 - (* |> Lwd.pure *) 38 - (* |> W.Scroll.area *) 39 - (* |> W.Box.box *) 40 - (* |>$ Ui.resize ~sh:1 ~mh:1000 *) 41 - (* |> W.size_logger) *) 42 - (* ; pString "| " *) 43 - test_input|>$ Ui.border ~focus_attr: (A.fg A.red) ~focus_style:Ui.Border.unicode_double ~thick:1 ~pad_w:1 ~pad_h:1 ~style:Ui.Border.unicode 22 + Ui.border 23 + ~thick:2 24 + ~style:Ui.Border.unicode 25 + ~label_top:"top" 26 + (Ui.vcat [ W.string "hi this is a ui element with a\n border"; W.string "hi" ]) 27 + |> Lwd.pure 28 + ; Ui.border 29 + ~thick:0 30 + ~pad_w:2 31 + ~pad_h:1 32 + ~style:Ui.Border.unicode_double 33 + ~label_bottom:"bottom" 34 + (Ui.vcat [ W.string "hi this is a ui element with a\n border"; W.string "hi" ]) 35 + |> Lwd.pure 36 + ; Ui.border 37 + ~thick:1 38 + ~style:Ui.Border.unicode_rounded 39 + ~label_top:"top" 40 + ~label_bottom:"bottom" 41 + (Ui.vcat [ W.string "hi this is a ui element with a\n border"; W.string "hi" ]) 42 + |> Lwd.pure 43 + ; Ui.border 44 + ~focus_attr:(A.fg A.red) 45 + ~focus_style:Ui.Border.unicode_double 46 + ~thick:2 47 + ~pad_w:2 48 + ~pad_h:1 49 + ~style:Ui.Border.unicode 50 + (Ui.vcat [ W.string "hi this is a ui element with a\n border"; W.string "hi" ]) 51 + |> Lwd.pure 52 + ; W.Box.box 53 + ~pad_w:2 54 + ~pad_h:1 55 + (Ui.vcat 56 + [ 57 + W.string "hi this is a ui element with an\n old style border box" 58 + ; W.string "hi" 59 + ] 60 + |> Lwd.pure) 61 + ; (* pString " |" *) 62 + (* ; (let og = *) 63 + (* Ui.vcat *) 64 + (* [ *) 65 + (* W.string "123456789000000000000000000000000000000000000000000000000000end" *) 66 + (* ; W.string "123456789000000000000000000000000000000000000000000000000000end" *) 67 + (* ] *) 68 + (* in *) 69 + (* og *) 70 + (* |> Lwd.pure *) 71 + (* |> W.Scroll.area *) 72 + (* |> W.Box.box *) 73 + (* |>$ Ui.resize ~sh:1 ~mh:1000 *) 74 + (* |> W.size_logger) *) 75 + (* ; pString "| " *) 76 + test_input 77 + |>$ Ui.border 78 + ~focus_attr:(A.fg A.red) 79 + ~focus_style:Ui.Border.unicode_double 80 + ~thick:1 81 + ~pad_w:1 82 + ~pad_h:1 83 + ~style:Ui.Border.unicode 44 84 ] 45 85 ;; 46 86 ··· 286 326 ] 287 327 ;; 288 328 289 - Ui.keyboard_area 329 + Ui.keyboard_area;; 290 330 291 331 let w_5 = 292 332 let focus = Focus.make () in
+88
test_render.ml
··· 1 + open Jj_tui 2 + open Notty 3 + open Notty.I 4 + 5 + let test_node : Render_jj_graph.node = 6 + { 7 + parents = []; 8 + creation_time = Int64.zero; 9 + working_copy = true; 10 + immutable = false; 11 + wip = false; 12 + change_id = "ztooztwk"; 13 + commit_id = "235795c5"; 14 + description = "(no description set)"; 15 + bookmarks = []; 16 + author_email = "eli.jambu@gmail.com"; 17 + author_timestamp = "2026-01-15 14:05:59"; 18 + empty = true; 19 + hidden = false; 20 + divergent = false; 21 + is_preview = false; 22 + } 23 + 24 + let render_commit (node : Render_jj_graph.node) : image = 25 + let open Notty.A in 26 + let styled_text attr text = string attr text in 27 + let change_id_short = 28 + String.sub node.change_id 0 (min 8 (String.length node.change_id)) 29 + in 30 + let commit_id_short = 31 + String.sub node.commit_id 0 (min 8 (String.length node.commit_id)) 32 + in 33 + let description_line = 34 + match String.split_on_char '\n' node.description with 35 + | first :: _ when String.trim first <> "" -> String.trim first 36 + | _ -> "(no description set)" 37 + in 38 + 39 + let line1 = 40 + hcat 41 + [ 42 + styled_text (fg lightcyan ++ st bold) change_id_short; 43 + styled_text (fg white ++ st dim) (" " ^ node.author_email); 44 + styled_text (fg white ++ st dim) (" " ^ node.author_timestamp); 45 + styled_text (fg cyan ++ st dim) (" " ^ commit_id_short); 46 + ] 47 + in 48 + 49 + let description_with_prefix = 50 + if node.empty then "(empty) " ^ description_line else description_line 51 + in 52 + let line2 = styled_text (fg white ++ st dim) description_with_prefix in 53 + vcat [ line1; line2 ] 54 + 55 + let () = 56 + let content = render_commit test_node in 57 + let graph = string A.empty "@ " in 58 + 59 + Printf.printf "Content height: %d\n" (height content); 60 + Printf.printf "Content width: %d\n" (width content); 61 + Printf.printf "\nExpected output:\n"; 62 + Printf.printf "@ ztooztwk eli.jambu@gmail.com 2026-01-15 14:05:59 235795c5\n"; 63 + Printf.printf "│ (empty) (no description set)\n"; 64 + Printf.printf "\nActual rendering test:\n"; 65 + 66 + let node_glyphs = [ "○"; "@"; "◌"; "◆" ] in 67 + let graph_continuation = 68 + let chars = "@ " in 69 + let replaced = ref chars in 70 + List.iter 71 + (fun glyph -> 72 + replaced := Str.global_replace (Str.regexp_string glyph) "│" !replaced) 73 + node_glyphs; 74 + string A.empty !replaced 75 + in 76 + 77 + let lines = 78 + List.init (height content) (fun i -> 79 + let line_img = vcrop i 1 content in 80 + if i = 0 then hcat [ graph; line_img ] 81 + else hcat [ graph_continuation; line_img ]) 82 + in 83 + let result = vcat lines in 84 + 85 + Printf.printf "Result height: %d\n" (height result); 86 + Printf.printf "Result width: %d\n" (width result); 87 + 88 + ()
+73 -47
widget-test/main.ml
··· 1 - let test_input= 2 - let inp_var =("hi there",5)|>Lwd.var in 3 - let inp_text= inp_var|>Lwd.get in 1 + let test_input = 2 + let inp_var = ("hi there", 5) |> Lwd.var in 3 + let inp_text = inp_var |> Lwd.get in 4 4 5 - W.edit_field inp_text ~on_change:(fun x->Lwd.set inp_var x) ~on_submit:(fun x->()) 5 + W.edit_field inp_text 6 + ~on_change:(fun x -> Lwd.set inp_var x) 7 + ~on_submit:(fun x -> ()) 6 8 7 9 let test_focused_border = 8 10 let focus = Focus.make () in 9 11 let content = W.string "Click to focus this border" in 10 - content 11 - |> Lwd.pure 12 - |> Lwd.map ~f:(fun ui -> 13 - ui 14 - |> Ui.keyboard_area ~focus:(Focus.status focus) (fun _ -> `Unhandled) 15 - |> Ui.border 16 - ~thick:1 17 - ~pad_w:2 18 - ~pad_h:1 19 - ~attr:(A.fg A.white) 20 - ~style:Ui.Border.unicode 21 - ~focus_attr:(A.fg A.blue) 22 - ~focus_style:Ui.Border.unicode_bold 23 - |> Ui.mouse_area (fun ~x:_ ~y:_ _ -> 24 - Focus.request focus; 25 - `Handled)) 12 + content |> Lwd.pure 13 + |> Lwd.map ~f:(fun ui -> 14 + ui 15 + |> Ui.keyboard_area ~focus:(Focus.status focus) (fun _ -> `Unhandled) 16 + |> Ui.border ~thick:1 ~pad_w:2 ~pad_h:1 ~attr:(A.fg A.white) 17 + ~style:Ui.Border.unicode ~focus_attr:(A.fg A.blue) 18 + ~focus_style:Ui.Border.unicode_bold 19 + |> Ui.mouse_area (fun ~x:_ ~y:_ _ -> 20 + Focus.request focus; 21 + `Handled)) 26 22 27 23 let w_0 = 28 24 W.vbox 29 25 [ 30 26 W.hbox 31 27 [ 32 - Ui.border ~thick:1 ~style:Ui.Border.unicode (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 33 - Ui.border ~thick:1 ~style:Ui.Border.unicode_double (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 34 - Ui.border ~thick:0 ~style:Ui.Border.unicode_rounded (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 35 - 36 - Ui.border ~thick: 2 ~pad_w:2 ~pad_h:1 ~style:Ui.Border.unicode (Ui.vcat [W.string "hi this is a ui element with a\n border"; W.string "hi"])|>Lwd.pure; 37 - 38 - W.Box.box ~pad_w:2 ~pad_h:1 (Ui.vcat [W.string "hi this is a ui element with an\n old style border box"; W.string "hi"]|>Lwd.pure); 28 + Ui.border ~thick:1 ~style:Ui.Border.unicode 29 + (Ui.vcat 30 + [ 31 + W.string "hi this is a ui element with a\n border"; 32 + W.string "hi"; 33 + ]) 34 + |> Lwd.pure; 35 + Ui.border ~thick:1 ~style:Ui.Border.unicode_double 36 + (Ui.vcat 37 + [ 38 + W.string "hi this is a ui element with a\n border"; 39 + W.string "hi"; 40 + ]) 41 + |> Lwd.pure; 42 + Ui.border ~thick:0 ~style:Ui.Border.unicode_rounded 43 + (Ui.vcat 44 + [ 45 + W.string "hi this is a ui element with a\n border"; 46 + W.string "hi"; 47 + ]) 48 + |> Lwd.pure; 49 + Ui.border ~thick:2 ~pad_w:2 ~pad_h:1 ~style:Ui.Border.unicode 50 + (Ui.vcat 51 + [ 52 + W.string "hi this is a ui element with a\n border"; 53 + W.string "hi"; 54 + ]) 55 + |> Lwd.pure; 56 + W.Box.box ~pad_w:2 ~pad_h:1 57 + (Ui.vcat 58 + [ 59 + W.string 60 + "hi this is a ui element with an\n old style border box"; 61 + W.string "hi"; 62 + ] 63 + |> Lwd.pure); 39 64 (* pString " |" *) 40 - (* ; (let og = *) 41 - (* Ui.vcat *) 42 - (* [ *) 43 - (* W.string "123456789000000000000000000000000000000000000000000000000000end" *) 44 - (* ; W.string "123456789000000000000000000000000000000000000000000000000000end" *) 45 - (* ] *) 46 - (* in *) 47 - (* og *) 48 - (* |> Lwd.pure *) 49 - (* |> W.Scroll.area *) 50 - (* |> W.Box.box *) 51 - (* |>$ Ui.resize ~sh:1 ~mh:1000 *) 52 - (* |> W.size_logger) *) 53 - (* ; pString "| " *) 54 - test_input|>$ Ui.border ~thick:1 ~pad_w:1 ~pad_h:1 ~style:Ui.Border.unicode 55 - ] 56 - ; W.string " " |> Lwd.pure 57 - ; W.string "Test focused border (click to focus):" |> Lwd.pure 58 - ; test_focused_border 59 - ] 65 + (* ; (let og = *) 66 + (* Ui.vcat *) 67 + (* [ *) 68 + (* W.string "123456789000000000000000000000000000000000000000000000000000end" *) 69 + (* ; W.string "123456789000000000000000000000000000000000000000000000000000end" *) 70 + (* ] *) 71 + (* in *) 72 + (* og *) 73 + (* |> Lwd.pure *) 74 + (* |> W.Scroll.area *) 75 + (* |> W.Box.box *) 76 + (* |>$ Ui.resize ~sh:1 ~mh:1000 *) 77 + (* |> W.size_logger) *) 78 + (* ; pString "| " *) 79 + test_input 80 + |>$ Ui.border ~thick:1 ~pad_w:1 ~pad_h:1 ~style:Ui.Border.unicode; 81 + ]; 82 + W.string " " |> Lwd.pure; 83 + W.string "Test focused border (click to focus):" |> Lwd.pure; 84 + test_focused_border; 85 + ]