a code review tool

feat(tui): add code review view

+839 -312
+7 -1
dune
··· 2 2 3 3 (env 4 4 (dev 5 - (flags :standard %{dune-warnings}))) 5 + (flags :standard %{dune-warnings}) 6 + (link_flags 7 + (-ccopt -fuse-ld=lld))) 8 + (release 9 + (ocamlopt_flags :standard %{dune-warnings} -flambda2-reaper) 10 + (link_flags 11 + (-ccopt -fuse-ld=lld))))
+1
lib/backend/lens_backend.ml
··· 1 1 module Store = Store 2 2 module Change_id = Change_id 3 3 module User_id = User_id 4 + module Operation_id = Operation_id
+9
lib/backend/store.ml
··· 115 115 (List.map subtree ~f:(fun (_, tree) -> Tree.get tree [] >|= Entry.approve_exn)))) 116 116 >|= List.concat 117 117 ;; 118 + 119 + let is_approved_by t change_id user_id ~commit_id = 120 + let open Lwt.Syntax in 121 + let* all_approvals = approvals t change_id in 122 + Lwt.return 123 + (List.exists all_approvals ~f:(fun (a : Approval.t) -> 124 + User_id.equal a.user_id user_id 125 + && String.equal (Operation_id.to_string a.operation_id) commit_id)) 126 + ;;
+1
lib/backend/store.mli
··· 35 35 val pending_requests : t -> Change_id.t -> Request.t list Lwt.t 36 36 val approve : t -> Change_id.t -> User_id.t -> Operation_id.t -> unit Lwt.t 37 37 val approvals : t -> Change_id.t -> Approval.t list Lwt.t 38 + val is_approved_by : t -> Change_id.t -> User_id.t -> commit_id:string -> bool Lwt.t
+365
lib/tui/code_review_view.ml
··· 1 + open! Core 2 + open! Bonsai_term 3 + open! Import 4 + 5 + let shortcut ~(flavor : Catppuccin.Flavor.t) label desc = 6 + let c color = Catppuccin.color ~flavor color in 7 + View.hcat 8 + [ View.text 9 + ~attrs:[ Attr.fg (c Base); Attr.bg (c Subtext0); Attr.bold ] 10 + (" " ^ label ^ " ") 11 + ; View.text ~attrs:[ Attr.fg (c Subtext0) ] (" " ^ desc) 12 + ] 13 + ;; 14 + 15 + let render_diff_line ~(flavor : Catppuccin.Flavor.t) (line : string) = 16 + let c color = Catppuccin.color ~flavor color in 17 + let color = 18 + if String.is_prefix line ~prefix:"+" && not (String.is_prefix line ~prefix:"+++") 19 + then c Green 20 + else if String.is_prefix line ~prefix:"-" && not (String.is_prefix line ~prefix:"---") 21 + then c Red 22 + else if String.is_prefix line ~prefix:"@@" 23 + then c Blue 24 + else if String.is_prefix line ~prefix:"diff" 25 + || String.is_prefix line ~prefix:"index" 26 + || String.is_prefix line ~prefix:"---" 27 + || String.is_prefix line ~prefix:"+++" 28 + then c Overlay1 29 + else c Text 30 + in 31 + View.text ~attrs:[ Attr.fg color ] line 32 + ;; 33 + 34 + let render_file_list_entry 35 + ~(flavor : Catppuccin.Flavor.t) 36 + ~pane_width 37 + ~selected 38 + ~reviewed 39 + path 40 + = 41 + let c color = Catppuccin.color ~flavor color in 42 + let bg_attrs = if selected then [ Attr.bg (c Surface0) ] else [] in 43 + let indicator = 44 + if reviewed 45 + then View.text ~attrs:([ Attr.fg (c Green) ] @ bg_attrs) "\xe2\x9c\x93" 46 + else if selected 47 + then View.text ~attrs:([ Attr.fg (c Mauve) ] @ bg_attrs) ">" 48 + else View.text ~attrs:bg_attrs " " 49 + in 50 + let path_display = 51 + let truncated = 52 + if String.length path > pane_width - 3 53 + then String.prefix path (pane_width - 6) ^ "..." 54 + else path 55 + in 56 + View.text ~attrs:([ Attr.fg (c Text) ] @ bg_attrs) truncated 57 + in 58 + let padding_width = 59 + Int.max 0 (pane_width - View.width indicator - 1 - View.width path_display) 60 + in 61 + let padding = 62 + if padding_width > 0 63 + then View.text ~attrs:bg_attrs (String.make padding_width ' ') 64 + else View.none 65 + in 66 + View.hcat [ indicator; View.text ~attrs:bg_attrs " "; path_display; padding ] 67 + ;; 68 + 69 + let render_file_list 70 + ~(flavor : Catppuccin.Flavor.t) 71 + ~pane_width 72 + ~selected_idx 73 + ~(reviewed_files : String.Set.t) 74 + file_paths 75 + = 76 + View.vcat 77 + (List.mapi file_paths ~f:(fun i path -> 78 + render_file_list_entry 79 + ~flavor 80 + ~pane_width 81 + ~selected:(i = selected_idx) 82 + ~reviewed:(Set.mem reviewed_files path) 83 + path)) 84 + ;; 85 + 86 + let render_footer ~(flavor : Catppuccin.Flavor.t) ~reviewed_count ~total_count = 87 + let shortcut = shortcut ~flavor in 88 + let progress = 89 + View.text 90 + ~attrs:[ Attr.fg (Catppuccin.color ~flavor Blue) ] 91 + (sprintf " (%d/%d reviewed)" reviewed_count total_count) 92 + in 93 + View.hcat 94 + [ shortcut "j/k" "select file" 95 + ; View.text " " 96 + ; shortcut "Shift+R" "mark reviewed" 97 + ; View.text " " 98 + ; shortcut "PgDn/PgUp" "scroll diff" 99 + ; View.text " " 100 + ; shortcut "esc" "back" 101 + ; progress 102 + ] 103 + ;; 104 + 105 + let render_approval_prompt ~(flavor : Catppuccin.Flavor.t) ~(dimensions : Dimensions.t) = 106 + let c color = Catppuccin.color ~flavor color in 107 + let shortcut = shortcut ~flavor in 108 + let prompt_content = 109 + View.vcat 110 + [ View.text ~attrs:[ Attr.fg (c Text) ] "All files reviewed. Approve this change?" 111 + ; View.text "" 112 + ; View.hcat [ shortcut "enter" "approve"; View.text " "; shortcut "esc" "cancel" ] 113 + ] 114 + in 115 + let prompt_box = 116 + Bonsai_tui_border_box.view 117 + ~line_type:Round_corners 118 + ~left_padding:2 119 + ~right_padding:2 120 + ~top_padding:1 121 + ~bottom_padding:1 122 + ~title:(View.text ~attrs:[ Attr.fg (c Mauve); Attr.bold ] " Approve Change ") 123 + ~attrs:[ Attr.fg (c Overlay1); Attr.bg (c Mantle) ] 124 + prompt_content 125 + in 126 + View.center prompt_box ~within:dimensions 127 + ;; 128 + 129 + let diff_pane ~dimensions ~change_id ~selected_file_path (graph @ local) = 130 + let diff_poll_input = 131 + let%arr selected_file_path and change_id in 132 + selected_file_path, change_id 133 + in 134 + let file_diff = 135 + Bonsai.Edge.Poll.effect_on_change 136 + Bonsai.Edge.Poll.Starting.empty 137 + diff_poll_input 138 + ~equal_input:[%equal: string option * string] 139 + ~effect: 140 + (Bonsai.return 141 + @@ fun (path_opt, change_id) -> 142 + match path_opt with 143 + | None -> Ui_effect.return None 144 + | Some path -> 145 + let open Effect.Let_syntax in 146 + let%bind diff = 147 + Effect.of_lwt_thunk (fun () -> Jj.fetch_file_diff ~change_id ~path) 148 + in 149 + return (Some diff)) 150 + graph 151 + in 152 + let diff_content_view = 153 + match%sub file_diff with 154 + | None -> Bonsai.return (View.text "Loading...") 155 + | Some None -> Bonsai.return (View.text "Select a file to view diff") 156 + | Some (Some diff_text) -> 157 + let%arr diff_text 158 + and flavor = Catppuccin.flavor graph in 159 + let lines = String.split_lines diff_text in 160 + (match List.map lines ~f:(render_diff_line ~flavor) with 161 + | [] -> View.text "No diff content" 162 + | line_views -> View.vcat line_views) 163 + in 164 + let%sub ~view, ~inject, ~less_keybindings_handler:_, ~is_at_bottom:_, ~stuck_to_bottom:_ 165 + = 166 + Bonsai_tui_scroller.component ~dimensions diff_content_view graph 167 + in 168 + (* Reset scroll when file changes *) 169 + Bonsai.Edge.on_change 170 + selected_file_path 171 + ~trigger:`After_display 172 + ~equal:[%equal: string option] 173 + ~callback: 174 + (let%arr inject in 175 + fun _path -> inject (Scroll_to { top = 0; bottom = 0 })) 176 + graph; 177 + view, inject 178 + ;; 179 + 180 + (* --- Main component --- *) 181 + 182 + let component 183 + ~(dimensions : Dimensions.t Bonsai.t) 184 + ~(change_id : string Bonsai.t) 185 + ~(commit_id : _ Bonsai.t) 186 + ~(on_exit : unit Effect.t Bonsai.t) 187 + ~(on_approve : unit Effect.t Bonsai.t) 188 + ~(reviewed_files : String.Set.t Bonsai.t) 189 + ~(set_reviewed_files : (String.Set.t -> unit Effect.t) Bonsai.t) 190 + (graph @ local) 191 + = 192 + ignore commit_id; 193 + (* Fetch diff_stat internally *) 194 + let diff_stat = 195 + Bonsai.Edge.Poll.effect_on_change 196 + Bonsai.Edge.Poll.Starting.empty 197 + change_id 198 + ~equal_input:[%equal: string] 199 + ~effect: 200 + (Bonsai.return 201 + @@ fun change_id -> 202 + Effect.of_lwt_thunk (fun () -> Jj.fetch_diff_stat ~change_id)) 203 + graph 204 + in 205 + let file_paths = 206 + match%sub diff_stat with 207 + | None -> Bonsai.return [] 208 + | Some diff_stat -> 209 + let%arr diff_stat in 210 + List.map diff_stat.entries ~f:(fun (e : Jj.Diff_stat.Entry.t) -> e.path) 211 + in 212 + let num_files = 213 + let%arr file_paths in 214 + List.length file_paths 215 + in 216 + (* Internal state *) 217 + let selected_file_idx, set_selected_file_idx = Bonsai.state 0 graph in 218 + let showing_approval_prompt, set_showing_approval_prompt = Bonsai.state false graph in 219 + (* Reset when the change being reviewed changes *) 220 + Bonsai.Edge.on_change 221 + change_id 222 + ~trigger:`After_display 223 + ~equal:[%equal: string] 224 + ~callback: 225 + (let%arr set_selected_file_idx and set_showing_approval_prompt in 226 + fun _id -> 227 + let open Effect.Let_syntax in 228 + let%bind () = set_selected_file_idx 0 in 229 + set_showing_approval_prompt false) 230 + graph; 231 + let selected_file_path = 232 + let%arr selected_file_idx and file_paths in 233 + List.nth file_paths selected_file_idx 234 + in 235 + (* Diff pane sub-component *) 236 + let diff_dims = 237 + let%arr dimensions in 238 + let left_pane_width = Int.max 20 (dimensions.width * 25 / 100) in 239 + let separator_width = 1 in 240 + { Dimensions.width = dimensions.width - left_pane_width - separator_width 241 + ; height = dimensions.height - 1 242 + } 243 + in 244 + let diff_scroll_view, diff_inject = 245 + diff_pane ~dimensions:diff_dims ~change_id ~selected_file_path graph 246 + in 247 + (* Keyboard handler *) 248 + let handler = 249 + let%arr showing_approval_prompt 250 + and set_showing_approval_prompt 251 + and on_exit 252 + and on_approve 253 + and selected_file_idx 254 + and set_selected_file_idx 255 + and reviewed_files 256 + and set_reviewed_files 257 + and diff_inject 258 + and file_paths 259 + and num_files in 260 + fun (event : Event.t) -> 261 + if showing_approval_prompt 262 + then ( 263 + match event with 264 + | Key_press { key = Enter; mods = [] } -> on_approve 265 + | Key_press { key = Escape; mods = [] } -> set_showing_approval_prompt false 266 + | _ -> Effect.Ignore) 267 + else ( 268 + match event with 269 + | Key_press { key = Escape; mods = [] } -> on_exit 270 + | Key_press { key = ASCII 'j'; mods = [] } 271 + | Key_press { key = Arrow `Down; mods = [] } -> 272 + if num_files = 0 273 + then Effect.Ignore 274 + else set_selected_file_idx (Int.min (selected_file_idx + 1) (num_files - 1)) 275 + | Key_press { key = ASCII 'k'; mods = [] } 276 + | Key_press { key = Arrow `Up; mods = [] } -> 277 + set_selected_file_idx (Int.max (selected_file_idx - 1) 0) 278 + | Key_press { key = ASCII 'R'; mods = [] } -> 279 + (match List.nth file_paths selected_file_idx with 280 + | None -> Effect.Ignore 281 + | Some path -> 282 + let open Effect.Let_syntax in 283 + let new_reviewed = Set.add reviewed_files path in 284 + let%bind () = set_reviewed_files new_reviewed in 285 + let all_reviewed = 286 + List.for_all file_paths ~f:(fun p -> Set.mem new_reviewed p) 287 + in 288 + if all_reviewed 289 + then set_showing_approval_prompt true 290 + else ( 291 + let next_unreviewed = 292 + let after = 293 + List.findi file_paths ~f:(fun i p -> 294 + i > selected_file_idx && not (Set.mem new_reviewed p)) 295 + in 296 + match after with 297 + | Some (idx, _) -> Some idx 298 + | None -> 299 + List.findi file_paths ~f:(fun _i p -> not (Set.mem new_reviewed p)) 300 + |> Option.map ~f:fst 301 + in 302 + match next_unreviewed with 303 + | Some idx -> set_selected_file_idx idx 304 + | None -> set_showing_approval_prompt true)) 305 + | Key_press { key = Page `Down; mods = [] } 306 + | Key_press { key = ASCII 'd'; mods = [ Ctrl ] } -> diff_inject Down_half_screen 307 + | Key_press { key = Page `Up; mods = [] } 308 + | Key_press { key = ASCII 'u'; mods = [ Ctrl ] } -> diff_inject Up_half_screen 309 + | Key_press { key = ASCII 'G'; mods = [] } -> diff_inject Bottom 310 + | Key_press { key = ASCII 'g'; mods = [] } -> diff_inject Top 311 + | Key_press { key = ASCII 'j'; mods = [ Ctrl ] } 312 + | Key_press { key = Arrow `Down; mods = [ Shift ] } -> diff_inject Down 313 + | Key_press { key = ASCII 'k'; mods = [ Ctrl ] } 314 + | Key_press { key = Arrow `Up; mods = [ Shift ] } -> diff_inject Up 315 + | _ -> Effect.Ignore) 316 + in 317 + (* Compose final view *) 318 + let view = 319 + let%arr dimensions 320 + and selected_file_idx 321 + and reviewed_files 322 + and showing_approval_prompt 323 + and diff_scroll_view 324 + and file_paths 325 + and flavor = Catppuccin.flavor graph in 326 + let c color = Catppuccin.color ~flavor color in 327 + let left_pane_width = Int.max 20 (dimensions.width * 25 / 100) in 328 + let file_list = 329 + render_file_list 330 + ~flavor 331 + ~pane_width:left_pane_width 332 + ~selected_idx:selected_file_idx 333 + ~reviewed_files 334 + file_paths 335 + in 336 + let separator_height = dimensions.height - 1 in 337 + let separator = 338 + View.vcat 339 + (List.init separator_height ~f:(fun _ -> 340 + View.text ~attrs:[ Attr.fg (c Overlay0) ] "\xe2\x94\x82")) 341 + in 342 + let footer = 343 + render_footer 344 + ~flavor 345 + ~reviewed_count:(Set.length reviewed_files) 346 + ~total_count:(List.length file_paths) 347 + in 348 + let main_content = View.hcat [ file_list; separator; diff_scroll_view ] in 349 + let bg = 350 + View.rectangle 351 + ~attrs:[ Attr.bg (c Base) ] 352 + ~width:dimensions.width 353 + ~height:dimensions.height 354 + () 355 + in 356 + let pinned_footer = View.pad ~t:(dimensions.height - 1) footer in 357 + let base_view = View.zcat [ pinned_footer; main_content; bg ] in 358 + if showing_approval_prompt 359 + then ( 360 + let overlay = render_approval_prompt ~flavor ~dimensions in 361 + View.zcat [ overlay; base_view ]) 362 + else base_view 363 + in 364 + ~view, ~handler 365 + ;;
+8
lib/tui/jj.ml
··· 87 87 ; lines_removed : int 88 88 ; status : string 89 89 } 90 + [@@deriving equal, sexp_of] 90 91 end 91 92 92 93 type t = ··· 94 95 ; total_added : int 95 96 ; total_removed : int 96 97 } 98 + [@@deriving equal, sexp_of] 97 99 end 98 100 99 101 let diff_stat_template = ··· 167 169 Lwt_process.pread ~stderr:`Dev_null ("", [| "jj"; "config"; "get"; "user.email" |]) 168 170 >|= String.strip 169 171 ;; 172 + 173 + let fetch_file_diff ~change_id ~path = 174 + Lwt_process.pread 175 + ~stderr:`Dev_null 176 + ("", [| "jj"; "diff"; "-r"; change_id; path; "--git" |]) 177 + ;;
+5
lib/tui/jj.mli
··· 34 34 ; lines_removed : int 35 35 ; status : string 36 36 } 37 + [@@deriving equal, sexp_of] 37 38 end 38 39 39 40 type t = ··· 41 42 ; total_added : int 42 43 ; total_removed : int 43 44 } 45 + [@@deriving equal, sexp_of] 44 46 end 45 47 46 48 (** Parse structured diff stat output from a jj template. *) ··· 60 62 61 63 (** Get the current user's email from jj config. *) 62 64 val get_user_email : unit -> string Lwt.t 65 + 66 + (** Fetch a unified diff for a specific file in a change. *) 67 + val fetch_file_diff : change_id:string -> path:string -> string Lwt.t
+96 -311
lib/tui/lens_tui.ml
··· 3 3 open! Import 4 4 open Bonsai.Let_syntax 5 5 6 - module Focus = struct 6 + module Route = struct 7 7 type t = 8 8 | Log 9 - | Edit_description of { change_id : string } 10 - | Request_review of { change_id : string } 9 + | Code_review of 10 + { change_id : string 11 + ; commit_id : string 12 + } 13 + [@@deriving equal, sexp_of] 11 14 end 12 15 13 - type t = 14 - { focus : Focus.t Bonsai.t 15 - ; set_focus : (Focus.t -> unit Effect.t) Bonsai.t 16 - ; editor : Editor.t 17 - ; log_view : Log_view.t 18 - ; textbox : Bonsai_tui_textbox.t Bonsai.t 19 - ; review_view : Review_view.t 20 - ; store : Lens_backend.Store.t 21 - ; exit : unit -> unit Effect.t 22 - } 23 - 24 - let handler t = 25 - let%arr focus = t.focus 26 - and set_focus = t.set_focus 27 - and editor_handler = Editor.handler t.editor 28 - and editor_set_text = t.editor.set_text 29 - and refresh_log_changes = t.log_view.refresh_changes 30 - and refresh_log_reviews = t.log_view.refresh_reviews 31 - and log_handler = t.log_view.handler 32 - and selected_row = t.log_view.selected_row 33 - and set_selected_idx = t.log_view.set_selected_idx 34 - and textbox = t.textbox 35 - and refresh_review_view = t.review_view.refresh in 36 - fun (event : Event.t) -> 37 - match focus with 38 - | Request_review { change_id } -> 39 - (match event with 40 - | Key_press { key = Escape; mods = [] } -> 41 - let open Effect.Let_syntax in 42 - let%bind () = textbox.set "" in 43 - set_focus Log 44 - | Key_press { key = Enter; mods = [] } -> 45 - let reviewer_email = String.strip textbox.string in 46 - if String.is_empty reviewer_email 47 - then Effect.Ignore 48 - else 49 - let open Effect.Let_syntax in 50 - let%bind () = 51 - Effect.of_lwt_thunk (fun () -> 52 - Lens_backend.Store.request 53 - t.store 54 - (Lens_backend.Change_id.of_string change_id) 55 - (Lens_backend.User_id.of_string reviewer_email)) 56 - in 57 - let%bind () = textbox.set "" in 58 - let%bind () = refresh_review_view in 59 - let%bind () = refresh_log_reviews in 60 - set_focus Log 61 - | event -> textbox.handler event) 62 - | Edit_description { change_id } -> 63 - let open Effect.Let_syntax in 64 - let%bind action = editor_handler event in 65 - (match action with 66 - | None -> return () 67 - | Some Exit_without_saving -> set_focus Log 68 - | Some (Save_and_exit message) -> 69 - let%bind () = Effect.of_lwt_thunk (fun () -> Jj.describe ~change_id ~message) in 70 - let%bind () = refresh_log_changes in 71 - set_focus Log) 72 - | Log -> 73 - let%bind.Ui_effect captured = log_handler event in 74 - (match captured with 75 - | Captured -> Ui_effect.Ignore 76 - | Ignored -> 77 - (match event with 78 - | Key_press { key = ASCII 'q'; mods = [] } 79 - | Key_press { key = ASCII ('c' | 'C'); mods = [ Ctrl ] } -> t.exit () 80 - | Key_press { key = ASCII 'd'; mods = [] } -> 81 - (* Open editor for selected change *) 82 - (match selected_row with 83 - | None -> Effect.Ignore 84 - | Some (row : Graph_layout.Row.t) -> 85 - let open Effect.Let_syntax in 86 - let%bind () = editor_set_text row.change.description in 87 - set_focus (Edit_description { change_id = row.change.change_id })) 88 - | Key_press { key = ASCII 'r'; mods = [] } -> 89 - (* Open reviewer input for selected change *) 90 - (match selected_row with 91 - | None -> Effect.Ignore 92 - | Some (row : Graph_layout.Row.t) -> 93 - let open Effect.Let_syntax in 94 - let%bind () = textbox.set "" in 95 - set_focus (Request_review { change_id = row.change.change_id })) 96 - | Key_press { key = ASCII 'n'; mods = [] } -> 97 - (* Create new change, refresh log, open editor *) 98 - let open Effect.Let_syntax in 99 - let%bind () = Effect.of_lwt_thunk Jj.new_change in 100 - let%bind new_changes = Effect.of_lwt_thunk Jj.fetch_log in 101 - let%bind () = refresh_log_changes in 102 - let wc_idx = 103 - List.findi new_changes ~f:(fun _ (c : Jj.Change.t) -> 104 - c.current_working_copy) 105 - in 106 - (match wc_idx with 107 - | Some (idx, change) -> 108 - let%bind () = set_selected_idx (Fn.const idx) in 109 - let%bind () = editor_set_text "" in 110 - set_focus (Edit_description { change_id = change.change_id }) 111 - | None -> Effect.Ignore) 112 - | _ -> Effect.Ignore)) 113 - ;; 114 - 115 16 let app ~store = 116 17 Staged.stage 117 18 @@ fun ~exit ~(dimensions : Dimensions.t Bonsai.t) (graph @ local) -> ··· 119 20 Catppuccin.set_flavor_within_app 120 21 flavor 121 22 (fun (graph @ local) -> 122 - let focus, set_focus = Bonsai.state (Log : Focus.t) graph in 123 - let pad_w = 2 in 124 - let pad_h = 1 in 125 - let shortcuts_height = 1 in 126 - let separator_height = 1 in 127 - let review_info_height = 1 in 128 - let top_dims = 129 - let%arr dimensions in 130 - let top_h = (dimensions.height * 60 / 100) - pad_h in 131 - { Dimensions.width = dimensions.width - (2 * pad_w); height = top_h } 23 + let route, set_route = Bonsai.state (Route.Log : Route.t) graph in 24 + (* Review progress survives route changes *) 25 + let reviewed_files_map, set_reviewed_files_map = 26 + Bonsai.state String.Map.empty graph 132 27 in 133 - let bottom_dims = 134 - let%arr dimensions in 135 - let top_h = (dimensions.height * 60 / 100) - pad_h in 136 - { Dimensions.width = dimensions.width - (2 * pad_w) 137 - ; height = 138 - dimensions.height 139 - - pad_h 140 - - top_h 141 - - separator_height 142 - - review_info_height 143 - - pad_h 144 - - shortcuts_height 145 - - pad_h 146 - } 147 - in 148 - let log_view = Log_view.component ~dimensions:top_dims ~store graph in 149 - let editor = Editor.component ~dimensions graph in 150 - let selected_change = 151 - let%arr selected_row = log_view.selected_row in 152 - Option.map selected_row ~f:Graph_layout.Row.change 153 - in 154 - let bottom_view = 155 - Diff_view.component ~dimensions:bottom_dims ~selected_change graph 156 - in 157 - let review_view = Review_view.component ~selected_change ~store graph in 158 - let textbox_focused = 159 - let%arr focus in 160 - match focus with 161 - | Request_review _ -> true 162 - | Log | Edit_description _ -> false 163 - in 164 - let textbox_cursor_attrs = 165 - let%arr flavor = Catppuccin.flavor graph in 166 - [ Attr.bg (Catppuccin.color ~flavor Text) ] 167 - in 168 - let textbox_text_attrs = 169 - let%arr flavor = Catppuccin.flavor graph in 170 - [ Attr.fg (Catppuccin.color ~flavor Text) ] 171 - in 172 - let textbox = 173 - Bonsai_tui_textbox.component 174 - ~cursor_attrs:textbox_cursor_attrs 175 - ~text_attrs:textbox_text_attrs 176 - ~is_focused:textbox_focused 177 - graph 178 - in 179 - let t = { set_focus; focus; editor; log_view; textbox; review_view; store; exit } in 180 - let set_cursor = Effect.set_cursor graph in 181 - let%sub view, cursor_pos = 182 - let%arr top_view = log_view.view 183 - and bottom_view 184 - and review_info_view = review_view.view 185 - and dimensions 186 - and focus 187 - and editor_view = editor.view 188 - and get_cursor_pos = editor.get_cursor_position 189 - and textbox = textbox 190 - and flavor = Catppuccin.flavor graph in 191 - let c color = Catppuccin.color ~flavor color in 192 - let content_width = dimensions.width - (2 * pad_w) in 193 - let separator = 194 - View.pad 195 - ~l:pad_w 196 - (View.text 197 - ~attrs:[ Attr.fg (c Overlay0) ] 198 - (String.concat (List.init content_width ~f:(fun _ -> "\xe2\x94\x80")))) 199 - in 200 - let crop_w v = 201 - View.crop ~r:(max 0 (View.width v - (dimensions.width - (2 * pad_w)))) v 202 - in 203 - let inset v = View.pad ~l:pad_w ~t:pad_h (crop_w v) in 204 - let inset_no_top v = View.pad ~l:pad_w (crop_w v) in 205 - let shortcut label desc = 206 - let label_color = c Subtext0 in 207 - View.hcat 208 - [ View.text 209 - ~attrs:[ Attr.fg (c Base); Attr.bg label_color; Attr.bold ] 210 - (" " ^ label ^ " ") 211 - ; View.text ~attrs:[ Attr.fg label_color ] (" " ^ desc) 212 - ] 213 - in 214 - let footer = 215 - View.pad 216 - ~l:pad_w 217 - (View.hcat 218 - (match focus with 219 - | Edit_description _ -> 220 - [ shortcut "ctrl-s" "save"; View.text " "; shortcut "esc" "cancel" ] 221 - | Request_review _ -> 222 - [ shortcut "enter" "submit"; View.text " "; shortcut "esc" "cancel" ] 223 - | Log -> 224 - [ shortcut "j/k" "navigate" 225 - ; View.text " " 226 - ; shortcut "n" "new" 227 - ; View.text " " 228 - ; shortcut "d" "describe" 229 - ; View.text " " 230 - ; shortcut "r" "review" 231 - ; View.text " " 232 - ; shortcut "q" "quit" 233 - ])) 234 - in 235 - let content = 236 - View.vcat 237 - [ inset top_view 238 - ; separator 239 - ; inset_no_top review_info_view 240 - ; inset_no_top bottom_view 241 - ] 242 - in 243 - let pinned_footer = View.pad ~t:(dimensions.height - 2) footer in 244 - let bg = 245 - View.rectangle 246 - ~attrs:[ Attr.bg (c Base) ] 247 - ~width:dimensions.width 248 - ~height:dimensions.height 249 - () 250 - in 251 - let base_view = View.zcat [ pinned_footer; content; bg ] in 252 - match focus with 253 - | Log -> base_view, None 254 - | Request_review _ -> 255 - let tb_width = max 30 (dimensions.width - 40) in 256 - let tb_view = View.with_colors textbox.view ~fg:(c Text) ~bg:(c Mantle) in 257 - let tb_pad = max 0 (tb_width - View.width tb_view) in 258 - let padded_tb = 259 - View.hcat 260 - [ tb_view 261 - ; View.rectangle 262 - ~attrs:[ Attr.bg (c Mantle) ] 263 - ~width:tb_pad 264 - ~height:1 265 - () 266 - ] 28 + let%sub final_view, final_handler = 29 + match%sub route with 30 + | Log -> 31 + let enter_review = 32 + let%arr set_route in 33 + fun (change : Jj.Change.t) -> 34 + let open Effect.Let_syntax in 35 + let%bind should_review = 36 + Effect.of_lwt_thunk (fun () -> 37 + let open Lwt.Syntax in 38 + let* user_email = Jj.get_user_email () in 39 + let change_id_t = Lens_backend.Change_id.of_string change.change_id in 40 + let user_id_t = Lens_backend.User_id.of_string user_email in 41 + let* reqs = Lens_backend.Store.pending_requests store change_id_t in 42 + let is_reviewer = 43 + List.exists reqs ~f:(fun (r : Lens_backend.Store.Request.t) -> 44 + Lens_backend.User_id.equal r.user_id user_id_t) 45 + in 46 + if is_reviewer 47 + then 48 + let* already_approved = 49 + Lens_backend.Store.is_approved_by 50 + store 51 + change_id_t 52 + user_id_t 53 + ~commit_id:change.commit_id 54 + in 55 + if already_approved then Lwt.return false else Lwt.return true 56 + else Lwt.return false) 57 + in 58 + if should_review 59 + then 60 + set_route 61 + (Route.Code_review 62 + { change_id = change.change_id; commit_id = change.commit_id }) 63 + else Effect.Ignore 64 + in 65 + let ~view, ~handler = 66 + Log_screen.component ~dimensions ~store ~exit ~enter_review graph 67 + in 68 + let%arr view and handler in 69 + view, handler 70 + | Code_review { change_id; commit_id } -> 71 + let reviewed_files = 72 + let%arr reviewed_files_map and change_id and commit_id in 73 + let key = change_id ^ ":" ^ commit_id in 74 + Map.find reviewed_files_map key |> Option.value ~default:String.Set.empty 75 + in 76 + let set_reviewed_files = 77 + let%arr set_reviewed_files_map 78 + and reviewed_files_map 79 + and change_id 80 + and commit_id in 81 + fun new_set -> 82 + let key = change_id ^ ":" ^ commit_id in 83 + set_reviewed_files_map (Map.set reviewed_files_map ~key ~data:new_set) 267 84 in 268 - let tb_box = 269 - Bonsai_tui_border_box.view 270 - ~line_type:Round_corners 271 - ~left_padding:1 272 - ~right_padding:1 273 - ~top_padding:0 274 - ~bottom_padding:0 275 - ~title: 276 - (View.text ~attrs:[ Attr.fg (c Mauve); Attr.bold ] " Request Review ") 277 - ~attrs:[ Attr.fg (c Overlay1); Attr.bg (c Mantle) ] 278 - padded_tb 85 + let on_exit = 86 + let%arr set_route in 87 + set_route Route.Log 279 88 in 280 - let overlay = View.center tb_box ~within:dimensions in 281 - View.zcat [ overlay; base_view ], None 282 - | Edit_description _ -> 283 - let editor_height = max 10 (dimensions.height / 3) in 284 - let editor_width = max 20 (dimensions.width - 20) in 285 - let padded_editor = 286 - let ev = View.with_colors editor_view ~fg:(c Text) ~bg:(c Mantle) in 287 - let pad_h = max 0 (editor_height - View.height ev) in 288 - View.vcat 289 - [ ev 290 - ; View.rectangle 291 - ~attrs:[ Attr.bg (c Mantle) ] 292 - ~width:editor_width 293 - ~height:pad_h 294 - () 295 - ] 89 + let on_approve = 90 + let%arr set_route and change_id and commit_id in 91 + let open Effect.Let_syntax in 92 + let%bind () = 93 + Effect.of_lwt_thunk (fun () -> 94 + let open Lwt.Syntax in 95 + let* user_email = Jj.get_user_email () in 96 + Lens_backend.Store.approve 97 + store 98 + (Lens_backend.Change_id.of_string change_id) 99 + (Lens_backend.User_id.of_string user_email) 100 + (Lens_backend.Operation_id.of_string commit_id)) 101 + in 102 + set_route Route.Log 296 103 in 297 - let editor_box = 298 - Bonsai_tui_border_box.view 299 - ~line_type:Round_corners 300 - ~left_padding:1 301 - ~right_padding:1 302 - ~top_padding:0 303 - ~bottom_padding:0 304 - ~title: 305 - (View.text ~attrs:[ Attr.fg (c Mauve); Attr.bold ] " Edit Description ") 306 - ~attrs:[ Attr.fg (c Overlay1); Attr.bg (c Mantle) ] 307 - padded_editor 104 + let ~view, ~handler = 105 + Code_review_view.component 106 + ~dimensions 107 + ~change_id 108 + ~commit_id 109 + ~on_exit 110 + ~on_approve 111 + ~reviewed_files 112 + ~set_reviewed_files 113 + graph 308 114 in 309 - let overlay = View.center editor_box ~within:dimensions in 310 - let final_view = View.zcat [ overlay; base_view ] in 311 - let cursor_pos = get_cursor_pos final_view in 312 - final_view, cursor_pos 115 + let%arr view and handler in 116 + view, handler 313 117 in 314 - Bonsai.Edge.on_change 315 - cursor_pos 316 - ~trigger:`After_display 317 - ~equal:[%equal: Position.t option] 318 - ~callback: 319 - (let%arr set_cursor and focus in 320 - fun pos -> 321 - match focus with 322 - | Log | Request_review _ -> 323 - Effect.Many [ Effect.hide_cursor; set_cursor None ] 324 - | Edit_description _ -> 325 - (match pos with 326 - | Some pos -> 327 - Effect.Many 328 - [ Effect.show_cursor 329 - ; set_cursor (Some { position = pos; kind = Bar }) 330 - ] 331 - | None -> Effect.Ignore)) 332 - graph; 333 - ~view, ~handler:(handler t)) 118 + ~view:final_view, ~handler:final_handler) 334 119 graph 335 120 ;; 336 121
+347
lib/tui/log_screen.ml
··· 1 + open! Core 2 + open! Bonsai_term 3 + open! Import 4 + open Bonsai.Let_syntax 5 + 6 + module Focus = struct 7 + type t = 8 + | Log 9 + | Edit_description of { change_id : string } 10 + | Request_review of { change_id : string } 11 + end 12 + 13 + type t = 14 + { focus : Focus.t Bonsai.t 15 + ; set_focus : (Focus.t -> unit Effect.t) Bonsai.t 16 + ; editor : Editor.t 17 + ; log_view : Log_view.t 18 + ; textbox : Bonsai_tui_textbox.t Bonsai.t 19 + ; review_view : Review_view.t 20 + ; store : Lens_backend.Store.t 21 + ; exit : unit -> unit Effect.t 22 + ; enter_review : (Jj.Change.t -> unit Effect.t) Bonsai.t 23 + } 24 + 25 + let handler t = 26 + let%arr focus = t.focus 27 + and set_focus = t.set_focus 28 + and editor_handler = Editor.handler t.editor 29 + and editor_set_text = t.editor.set_text 30 + and refresh_log_changes = t.log_view.refresh_changes 31 + and refresh_log_reviews = t.log_view.refresh_reviews 32 + and log_handler = t.log_view.handler 33 + and selected_row = t.log_view.selected_row 34 + and set_selected_idx = t.log_view.set_selected_idx 35 + and textbox = t.textbox 36 + and refresh_review_view = t.review_view.refresh 37 + and enter_review = t.enter_review in 38 + fun (event : Event.t) -> 39 + match focus with 40 + | Request_review { change_id } -> 41 + (match event with 42 + | Key_press { key = Escape; mods = [] } -> 43 + let open Effect.Let_syntax in 44 + let%bind () = textbox.set "" in 45 + set_focus Log 46 + | Key_press { key = Enter; mods = [] } -> 47 + let reviewer_email = String.strip textbox.string in 48 + if String.is_empty reviewer_email 49 + then Effect.Ignore 50 + else 51 + let open Effect.Let_syntax in 52 + let%bind () = 53 + Effect.of_lwt_thunk (fun () -> 54 + Lens_backend.Store.request 55 + t.store 56 + (Lens_backend.Change_id.of_string change_id) 57 + (Lens_backend.User_id.of_string reviewer_email)) 58 + in 59 + let%bind () = textbox.set "" in 60 + let%bind () = refresh_review_view in 61 + let%bind () = refresh_log_reviews in 62 + set_focus Log 63 + | event -> textbox.handler event) 64 + | Edit_description { change_id } -> 65 + let open Effect.Let_syntax in 66 + let%bind action = editor_handler event in 67 + (match action with 68 + | None -> return () 69 + | Some Exit_without_saving -> set_focus Log 70 + | Some (Save_and_exit message) -> 71 + let%bind () = Effect.of_lwt_thunk (fun () -> Jj.describe ~change_id ~message) in 72 + let%bind () = refresh_log_changes in 73 + set_focus Log) 74 + | Log -> 75 + let%bind.Ui_effect captured = log_handler event in 76 + (match captured with 77 + | Captured -> Ui_effect.Ignore 78 + | Ignored -> 79 + (match event with 80 + | Key_press { key = ASCII 'q'; mods = [] } 81 + | Key_press { key = ASCII ('c' | 'C'); mods = [ Ctrl ] } -> t.exit () 82 + | Key_press { key = ASCII 'd'; mods = [] } -> 83 + (match selected_row with 84 + | None -> Effect.Ignore 85 + | Some (row : Graph_layout.Row.t) -> 86 + let open Effect.Let_syntax in 87 + let%bind () = editor_set_text row.change.description in 88 + set_focus (Edit_description { change_id = row.change.change_id })) 89 + | Key_press { key = ASCII 'r'; mods = [] } -> 90 + (match selected_row with 91 + | None -> Effect.Ignore 92 + | Some (row : Graph_layout.Row.t) -> 93 + let open Effect.Let_syntax in 94 + let%bind () = textbox.set "" in 95 + set_focus (Request_review { change_id = row.change.change_id })) 96 + | Key_press { key = Enter; mods = [] } -> 97 + (match selected_row with 98 + | None -> Effect.Ignore 99 + | Some (row : Graph_layout.Row.t) -> enter_review row.change) 100 + | Key_press { key = ASCII 'n'; mods = [] } -> 101 + let open Effect.Let_syntax in 102 + let%bind () = Effect.of_lwt_thunk Jj.new_change in 103 + let%bind new_changes = Effect.of_lwt_thunk Jj.fetch_log in 104 + let%bind () = refresh_log_changes in 105 + let wc_idx = 106 + List.findi new_changes ~f:(fun _ (c : Jj.Change.t) -> 107 + c.current_working_copy) 108 + in 109 + (match wc_idx with 110 + | Some (idx, change) -> 111 + let%bind () = set_selected_idx (Fn.const idx) in 112 + let%bind () = editor_set_text "" in 113 + set_focus (Edit_description { change_id = change.change_id }) 114 + | None -> Effect.Ignore) 115 + | _ -> Effect.Ignore)) 116 + ;; 117 + 118 + let component 119 + ~(dimensions : Dimensions.t Bonsai.t) 120 + ~(store : Lens_backend.Store.t) 121 + ~(exit : unit -> unit Effect.t) 122 + ~(enter_review : (Jj.Change.t -> unit Effect.t) Bonsai.t) 123 + (graph @ local) 124 + = 125 + let focus, set_focus = Bonsai.state (Log : Focus.t) graph in 126 + let pad_w = 2 in 127 + let pad_h = 1 in 128 + let shortcuts_height = 1 in 129 + let separator_height = 1 in 130 + let review_info_height = 1 in 131 + let top_dims = 132 + let%arr dimensions in 133 + let top_h = (dimensions.height * 60 / 100) - pad_h in 134 + { Dimensions.width = dimensions.width - (2 * pad_w); height = top_h } 135 + in 136 + let bottom_dims = 137 + let%arr dimensions in 138 + let top_h = (dimensions.height * 60 / 100) - pad_h in 139 + { Dimensions.width = dimensions.width - (2 * pad_w) 140 + ; height = 141 + dimensions.height 142 + - pad_h 143 + - top_h 144 + - separator_height 145 + - review_info_height 146 + - pad_h 147 + - shortcuts_height 148 + - pad_h 149 + } 150 + in 151 + let log_view = Log_view.component ~dimensions:top_dims ~store graph in 152 + let editor = Editor.component ~dimensions graph in 153 + let selected_change = 154 + let%arr selected_row = log_view.selected_row in 155 + Option.map selected_row ~f:Graph_layout.Row.change 156 + in 157 + let bottom_view = 158 + Diff_view.component ~dimensions:bottom_dims ~selected_change graph 159 + in 160 + let review_view = Review_view.component ~selected_change ~store graph in 161 + let textbox_focused = 162 + let%arr focus in 163 + match focus with 164 + | Request_review _ -> true 165 + | Log | Edit_description _ -> false 166 + in 167 + let textbox_cursor_attrs = 168 + let%arr flavor = Catppuccin.flavor graph in 169 + [ Attr.bg (Catppuccin.color ~flavor Text) ] 170 + in 171 + let textbox_text_attrs = 172 + let%arr flavor = Catppuccin.flavor graph in 173 + [ Attr.fg (Catppuccin.color ~flavor Text) ] 174 + in 175 + let textbox = 176 + Bonsai_tui_textbox.component 177 + ~cursor_attrs:textbox_cursor_attrs 178 + ~text_attrs:textbox_text_attrs 179 + ~is_focused:textbox_focused 180 + graph 181 + in 182 + let t = 183 + { set_focus 184 + ; focus 185 + ; editor 186 + ; log_view 187 + ; textbox 188 + ; review_view 189 + ; store 190 + ; exit 191 + ; enter_review 192 + } 193 + in 194 + let set_cursor = Effect.set_cursor graph in 195 + let%sub view, cursor_pos = 196 + let%arr top_view = log_view.view 197 + and bottom_view 198 + and review_info_view = review_view.view 199 + and dimensions 200 + and focus 201 + and editor_view = editor.view 202 + and get_cursor_pos = editor.get_cursor_position 203 + and textbox 204 + and flavor = Catppuccin.flavor graph in 205 + let c color = Catppuccin.color ~flavor color in 206 + let content_width = dimensions.width - (2 * pad_w) in 207 + let separator = 208 + View.pad 209 + ~l:pad_w 210 + (View.text 211 + ~attrs:[ Attr.fg (c Overlay0) ] 212 + (String.concat (List.init content_width ~f:(fun _ -> "\xe2\x94\x80")))) 213 + in 214 + let crop_w v = 215 + View.crop ~r:(max 0 (View.width v - (dimensions.width - (2 * pad_w)))) v 216 + in 217 + let inset v = View.pad ~l:pad_w ~t:pad_h (crop_w v) in 218 + let inset_no_top v = View.pad ~l:pad_w (crop_w v) in 219 + let shortcut label desc = 220 + let label_color = c Subtext0 in 221 + View.hcat 222 + [ View.text 223 + ~attrs:[ Attr.fg (c Base); Attr.bg label_color; Attr.bold ] 224 + (" " ^ label ^ " ") 225 + ; View.text ~attrs:[ Attr.fg label_color ] (" " ^ desc) 226 + ] 227 + in 228 + let footer = 229 + View.pad 230 + ~l:pad_w 231 + (View.hcat 232 + (match focus with 233 + | Edit_description _ -> 234 + [ shortcut "ctrl-s" "save"; View.text " "; shortcut "esc" "cancel" ] 235 + | Request_review _ -> 236 + [ shortcut "enter" "submit"; View.text " "; shortcut "esc" "cancel" ] 237 + | Log -> 238 + [ shortcut "j/k" "navigate" 239 + ; View.text " " 240 + ; shortcut "n" "new" 241 + ; View.text " " 242 + ; shortcut "d" "describe" 243 + ; View.text " " 244 + ; shortcut "r" "request review" 245 + ; View.text " " 246 + ; shortcut "enter" "review" 247 + ; View.text " " 248 + ; shortcut "q" "quit" 249 + ])) 250 + in 251 + let content = 252 + View.vcat 253 + [ inset top_view 254 + ; separator 255 + ; inset_no_top review_info_view 256 + ; inset_no_top bottom_view 257 + ] 258 + in 259 + let pinned_footer = View.pad ~t:(dimensions.height - 2) footer in 260 + let bg = 261 + View.rectangle 262 + ~attrs:[ Attr.bg (c Base) ] 263 + ~width:dimensions.width 264 + ~height:dimensions.height 265 + () 266 + in 267 + let base_view = View.zcat [ pinned_footer; content; bg ] in 268 + match focus with 269 + | Log -> base_view, None 270 + | Request_review _ -> 271 + let tb_width = max 30 (dimensions.width - 40) in 272 + let tb_view = View.with_colors textbox.view ~fg:(c Text) ~bg:(c Mantle) in 273 + let tb_pad = max 0 (tb_width - View.width tb_view) in 274 + let padded_tb = 275 + View.hcat 276 + [ tb_view 277 + ; View.rectangle ~attrs:[ Attr.bg (c Mantle) ] ~width:tb_pad ~height:1 () 278 + ] 279 + in 280 + let tb_box = 281 + Bonsai_tui_border_box.view 282 + ~line_type:Round_corners 283 + ~left_padding:1 284 + ~right_padding:1 285 + ~top_padding:0 286 + ~bottom_padding:0 287 + ~title: 288 + (View.text ~attrs:[ Attr.fg (c Mauve); Attr.bold ] " Request Review ") 289 + ~attrs:[ Attr.fg (c Overlay1); Attr.bg (c Mantle) ] 290 + padded_tb 291 + in 292 + let overlay = View.center tb_box ~within:dimensions in 293 + View.zcat [ overlay; base_view ], None 294 + | Edit_description _ -> 295 + let editor_height = max 10 (dimensions.height / 3) in 296 + let editor_width = max 20 (dimensions.width - 20) in 297 + let padded_editor = 298 + let ev = View.with_colors editor_view ~fg:(c Text) ~bg:(c Mantle) in 299 + let pad_h = max 0 (editor_height - View.height ev) in 300 + View.vcat 301 + [ ev 302 + ; View.rectangle 303 + ~attrs:[ Attr.bg (c Mantle) ] 304 + ~width:editor_width 305 + ~height:pad_h 306 + () 307 + ] 308 + in 309 + let editor_box = 310 + Bonsai_tui_border_box.view 311 + ~line_type:Round_corners 312 + ~left_padding:1 313 + ~right_padding:1 314 + ~top_padding:0 315 + ~bottom_padding:0 316 + ~title: 317 + (View.text ~attrs:[ Attr.fg (c Mauve); Attr.bold ] " Edit Description ") 318 + ~attrs:[ Attr.fg (c Overlay1); Attr.bg (c Mantle) ] 319 + padded_editor 320 + in 321 + let overlay = View.center editor_box ~within:dimensions in 322 + let final_view = View.zcat [ overlay; base_view ] in 323 + let cursor_pos = get_cursor_pos final_view in 324 + final_view, cursor_pos 325 + in 326 + Bonsai.Edge.on_change 327 + cursor_pos 328 + ~trigger:`After_display 329 + ~equal:[%equal: Position.t option] 330 + ~callback: 331 + (let%arr set_cursor and focus in 332 + fun pos -> 333 + match focus with 334 + | Log | Request_review _ -> 335 + Effect.Many [ Effect.hide_cursor; set_cursor None ] 336 + | Edit_description _ -> 337 + (match pos with 338 + | Some pos -> 339 + Effect.Many 340 + [ Effect.show_cursor 341 + ; set_cursor (Some { position = pos; kind = Bar }) 342 + ] 343 + | None -> Effect.Ignore)) 344 + graph; 345 + let log_handler = handler t in 346 + ~view, ~handler:log_handler 347 + ;;