stitch

Note taking for messy minimalists

git clone git://mccd.space/stitch
commit 256397061fe5ae5649b4569c45bb53c8e45e0cbe
parent 12ba5ed258b3a1ff30c3ece030e8eeed0dc417e9
Author: Marc Coquand <marc@mccd.space>
Date:   Sat, 18 May 2024 13:03:40 -0500

Add support for arbitrary command in the note view

Diffstat:
Mbin/main.ml | 20+++++++++++++++++---
Alib/arbitrary_command.ml | 33+++++++++++++++++++++++++++++++++
Mlib/headlines.ml | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mlib/help_screen.ml | 1+
Mstitch.opam | 2+-
5 files changed, 128 insertions(+), 12 deletions(-)
diff --git a/bin/main.ml b/bin/main.ml
@@ -61,7 +61,9 @@ let envs =
 let headlines_cmd =
   let doc = "write notes seperately and compose" in
   let author = [ `S Manpage.s_authors; `P "Marc Coquand (mccd.space)" ] in
-  let bugs = [ `S Manpage.s_bugs; `P "Email bug reports to marc@mccd.space" ] in
+  let bugs =
+    [ `S Manpage.s_bugs; `P "Email bug reports to ~marcc/stitch-general@lists.sr.ht." ]
+  in
   let description =
     [ `S Manpage.s_description
     ; `P
@@ -138,6 +140,16 @@ let headlines_cmd =
          toggle the todo items."
     ]
   in
+  let commands =
+    [ `S "COMMANDS"
+    ; `P ""
+    ; `P
+        "You can run arbitrary commands in Stitch. These commands can make use of \
+         variable substitutions. Available substitutions are"
+    ; `I ("%(file)", "Currently selected file")
+    ; `I ("%(content)", "Currently selected content")
+    ]
+  in
   let credit =
     [ `S "CREDIT"
     ; `P "Stitch was inspired by the note-taking tool Howm for EMacs by HIRAOKA Kazuyuki."
@@ -147,9 +159,11 @@ let headlines_cmd =
     Cmd.info
       ~envs
       "stitch"
-      ~version:"0.0.2 ALPHA"
+      ~version:"0.0.3 ALPHA"
       ~doc
-      ~man:(List.concat [ bugs; author; description; example_set_up_basic; credit ])
+      ~man:
+        (List.concat
+           [ description; commands; example_set_up_basic; bugs; author; credit ])
   in
   Cmd.v info headlines_t
 
diff --git a/lib/arbitrary_command.ml b/lib/arbitrary_command.ml
@@ -0,0 +1,33 @@
+module Grep = Grep
+
+let run ~(on_return : string -> unit) t ~selected_file ~content ~command =
+  let command = String.split_on_char ' ' command in
+  (* Substitute after splitting to more easily deal with file names that have spaces *)
+  let command =
+    command
+    |> List.map (fun s ->
+      let s = Str.global_replace (Str.regexp "%(file)") selected_file s in
+      Str.global_replace (Str.regexp "%(content)") content s)
+  in
+  Common.Term.cursor t (Some (0, 0));
+  try
+    let result =
+      let open Shexp_process in
+      let open Shexp_process.Infix in
+      eval (chdir Grep.execution_directory (call command |- read_all))
+    in
+    Common.Term.cursor t None;
+    on_return result
+  with
+  | exn ->
+    let oc = open_out "/tmp/stitch-error" in
+    let commands_result = String.concat " " command in
+    Printf.fprintf
+      oc
+      "%s\n%s\n%s\n%s\n"
+      selected_file
+      commands_result
+      (Printexc.to_string exn)
+      (Printexc.get_backtrace ());
+    close_out oc;
+    on_return "ERROR: Failed to run command. Error written to /tmp/stitch-error"
diff --git a/lib/headlines.ml b/lib/headlines.ml
@@ -12,6 +12,8 @@ type state =
   ; content_pretty : string array
   ; goto_todos_view : (unit -> unit) -> unit
   ; goto_done_view : (unit -> unit) -> unit
+  ; output : string option
+  ; tag : string
   }
 
 let init ~goto_done_view ~goto_todos_view ~regexp =
@@ -27,7 +29,15 @@ let init ~goto_done_view ~goto_todos_view ~regexp =
     exit 0)
   else (
     let content_pretty = content |> Grep.pretty_format in
-    { pos = 0, 2; scroll = 0; content; content_pretty; goto_done_view; goto_todos_view })
+    { pos = 0, 2
+    ; scroll = 0
+    ; content
+    ; content_pretty
+    ; goto_done_view
+    ; goto_todos_view
+    ; output = None
+    ; tag = ""
+    })
 
 
 let title = I.strf ~attr:A.(st bold) "%s" "Note View" |> I.pad ~l:0 ~t:0
@@ -35,12 +45,21 @@ let title = I.strf ~attr:A.(st bold) "%s" "Note View" |> I.pad ~l:0 ~t:0
 (* TODO: Add page title *)
 let rec render
   t
-  ({ pos; scroll; content; content_pretty; goto_todos_view; goto_done_view } as state)
+  ({ pos; scroll; content; content_pretty; goto_todos_view; goto_done_view; tag; output }
+   as state)
   =
   let content_start = 2 in
+  let size_x, size_y = Common.Term.size t in
   let x, y = pos in
   let content_position = y - content_start in
   let img =
+    let output_info =
+      match output with
+      | Some line ->
+        I.strf "%s%s" (String.escaped line) (String.make size_x ' ')
+        |> I.pad ~t:(size_y - 1)
+      | None -> I.empty
+    in
     let dot =
       if Array.length content_pretty = 0
       then I.empty
@@ -54,18 +73,17 @@ let rec render
         (Array.to_seq content_pretty |> Seq.drop scroll |> Array.of_seq)
     in
     let open I in
-    Array.fold_left (fun sum el -> el </> sum) (title </> dot) elements
+    Array.fold_left (fun sum el -> sum </> el) (title </> dot </> output_info) elements
   in
-  let _, size_y = Common.Term.size t in
   Common.Term.image t img;
   let content_end = Array.length content_pretty + (content_start - 1) in
   let scroll_up () =
     let scroll = if y - content_start - scroll = 0 then max (scroll - 1) 0 else scroll in
-    render t { state with pos = x, max (y - 1) content_start; scroll }
+    render t { state with pos = x, max (y - 1) content_start; scroll; output = None }
   in
   let scroll_down () =
     let scroll = if y - scroll >= size_y - 1 then scroll + 1 else scroll in
-    render t { state with pos = x, min (y + 1) content_end; scroll }
+    render t { state with pos = x, min (y + 1) content_end; scroll; output = None }
   in
   match Common.Term.event t with
   | `End | `Key (`Escape, []) | `Key (`ASCII 'q', []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
@@ -75,7 +93,7 @@ let rec render
      | `Up -> scroll_up ())
   | `Resize _ -> render t state
   | `Mouse ((`Press _ | `Drag), (_, y), _) ->
-    render t { state with pos = 0, min y content_end }
+    render t { state with pos = 0, min y content_end; output = None }
   | `Key (`ASCII '?', []) -> Help_screen.render t { go_back = (fun () -> render t state) }
   | `Key (`ASCII '2', []) -> goto_todos_view (fun () -> render t state)
   | `Key (`ASCII '3', []) -> goto_done_view (fun () -> render t state)
@@ -112,7 +130,13 @@ let rec render
             Common.Term.cursor t None;
             render
               t
-              { state with content; content_pretty; pos = 0, content_start; scroll = 0 })
+              { state with
+                content
+              ; content_pretty
+              ; pos = 0, content_start
+              ; scroll = 0
+              ; tag
+              })
       ; on_cancel =
           (fun _ ->
             Common.Term.cursor t None;
@@ -127,6 +151,50 @@ let rec render
      | `Up -> scroll_up ()
      | `Down -> scroll_down ()
      | _ -> render t state)
+  | `Key (`ASCII '!', []) ->
+    let selected_file, _ = Array.get content content_position in
+    let selected_file = Grep.execution_directory ^ "/" ^ selected_file in
+    let content =
+      Array.map (fun (_, c) -> c) content |> Array.to_list |> String.concat "\n"
+    in
+    let (input_state : Input_prompt.state) =
+      { screen = img
+      ; user_input = ""
+      ; prompt = "COMMAND"
+      ; on_enter =
+          (fun command ->
+            if String.equal (String.trim command) String.empty
+            then (
+              Common.Term.cursor t None;
+              render t state)
+            else
+              Arbitrary_command.run
+                t
+                ~command:(Scanf.unescaped command)
+                ~content
+                ~selected_file
+                ~on_return:(fun result ->
+                  let content =
+                    Grep.get_tagged_headlines tag () |> Grep.parse_headlines
+                  in
+                  let content_pretty = Grep.pretty_format content in
+                  Common.Term.cursor t None;
+                  render
+                    t
+                    { state with
+                      content
+                    ; content_pretty
+                    ; pos = 0, content_start
+                    ; scroll = 0
+                    ; output = Some result
+                    }))
+      ; on_cancel =
+          (fun _ ->
+            Common.Term.cursor t None;
+            render t state)
+      }
+    in
+    Input_prompt.render t input_state
   | `Key (`ASCII 'e', []) | `Key (`Enter, []) ->
     (* Editor might be set with extra args, in that case we need to separate these *)
     let[@warning "-8"] (editor :: args) =
diff --git a/lib/help_screen.ml b/lib/help_screen.ml
@@ -29,6 +29,7 @@ let general_help_menu =
   ; "Note View", "1"
   ; "Todo View", "2"
   ; "Done View", "3"
+  ; "Run Shell Command", "!"
   ; "Edit File in $EDITOR", "Enter, e"
   ]
 
diff --git a/stitch.opam b/stitch.opam
@@ -1,7 +1,7 @@
 # This file is generated by dune, edit dune-project instead
 opam-version: "2.0"
 version: "efe45fe-dirty"
-synopsis: "A Note Composer"
+synopsis: "Note Composer"
 description:
   "A minimal CLI tool that allows you to compose notes together. Useful as part of a bigger system for building a PKM."
 maintainer: ["Marc Coquand"]