Module: Twin::Picker

Defined in:
lib/twin/picker.rb

Overview

Two-step fzf picker:

pick_program → tabular multi-line list, no preview
pick_paths   → multi-select within one program, glow renders the sync-file

Constant Summary collapse

STATUS_ICONS =
{
  source_newer:   "",
  target_newer:   "",
  in_sync:        "",
  missing_target: "!",
  missing_source: "!",
  both_missing:   "",
  disabled:       "·",
}.freeze
STATUS_COLORS =
{
  source_newer:   "\e[33m",   # yellow
  target_newer:   "\e[36m",   # cyan
  in_sync:        "\e[32m",   # green
  missing_target: "\e[31m",   # red
  missing_source: "\e[31m",   # red
  both_missing:   "\e[31m",   # red
  disabled:       "\e[2m",    # dim
}.freeze
BOLD =
"\e[1m"
DIM =
"\e[2m"
RESET =
"\e[0m"
SH_ENV =
{ "SHELL" => "/bin/sh" }.freeze

Class Method Summary collapse

Class Method Details

.bold(text) ⇒ Object



41
# File 'lib/twin/picker.rb', line 41

def bold(text)             = "#{BOLD}#{text}#{RESET}"

.build_apex_preview_cmd(tempfiles, cfg) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/twin/picker.rb', line 134

def build_apex_preview_cmd(tempfiles, cfg)
  # Map idx → file via a small TSV, awk picks the right one for {1}.
  mapfile = Tempfile.new(["twin-map-", ".tsv"])
  tempfiles.each { |i, path| mapfile.puts("#{i}\t#{path}") }
  mapfile.close
  ObjectSpace.define_finalizer(mapfile, ->(_) { File.unlink(mapfile.path) rescue nil })

  render_cmd = pick_renderer(cfg)

  %(F=$(awk -v id={1} -F'\\t' '$1==id {print $2}' #{mapfile.path}); ) +
    %([ -n "$F" ] && #{render_cmd})
end

.colorize(status, text) ⇒ Object



39
# File 'lib/twin/picker.rb', line 39

def colorize(status, text) = "#{STATUS_COLORS[status] || ''}#{text}#{RESET}"

.dim(text) ⇒ Object



40
# File 'lib/twin/picker.rb', line 40

def dim(text)              = "#{DIM}#{text}#{RESET}"

.format_delta(sm, tm) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/twin/picker.rb', line 191

def format_delta(sm, tm)
  return "" if sm.nil? || tm.nil?
  seconds = (sm - tm).to_i
  return "in sync" if seconds.abs < 60
  label = seconds > 0 ? "src" : "tgt"
  abs = seconds.abs
  unit =
    if abs >= 86400 then "#{abs / 86400}d"
    elsif abs >= 3600 then "#{abs / 3600}h"
    else "#{abs / 60}m"
    end
  "#{label} +#{unit}"
end

.pick_paths(program, cfg) ⇒ Object

Multi-select over the jobs of one program. Right pane shows the apex-rendered compact view (frontmatter + intro + selected block). Returns array of selected Jobs, :back on ESC, [] on empty confirm.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/twin/picker.rb', line 79

def pick_paths(program, cfg)
  jobs = program.jobs
  return [] if jobs.empty?

  path_width = jobs.map { |j| j.path.length }.max
  tempfiles  = write_compact_previews(program, jobs)

  rows = jobs.each_with_index.map do |j, i|
    icon  = STATUS_ICONS[j.status] || "?"
    delta = format_delta(j.source_mtime, j.target_mtime)
    line  = "#{icon}  #{j.path.ljust(path_width)}  #{delta}"
    "#{i}\t#{colorize(j.status, line)}"
  end

  preview_cmd = build_apex_preview_cmd(tempfiles, cfg)

  fzf = [
    "fzf",
    "--multi", "--ansi",
    "--delimiter=\t", "--with-nth=2",
    "--prompt=#{program.name} > ",
    "--header=#{program.name} — Tab toggles, Enter confirms",
    "--preview=#{preview_cmd}",
    "--preview-window=right:60%:wrap",
    "--height=100%", "--reverse",
    "--bind=ctrl-a:select-all",
    "--color=bg+:-1,hl+:reverse",
  ]

  output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: rows.join("\n"))
  return :back if status.exitstatus == 130   # ESC / Ctrl-C
  return [] unless status.success?
  return [] if output.strip.empty?

  output.lines.filter_map do |line|
    idx = line.split("\t", 2).first&.to_i
    jobs[idx] if idx
  end
ensure
  tempfiles&.each_value { |path| File.unlink(path) rescue nil }
end

.pick_program(programs) ⇒ Object

Multi-line entries (NUL-separated). Header line per program, indented body lines per job. Returns the selected Program or nil.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/twin/picker.rb', line 47

def pick_program(programs)
  raise "fzf not found in PATH" unless which("fzf")
  return nil if programs.empty?

  name_width = programs.map { |p| p.name.length }.max
  path_width = programs.flat_map { |p| p.jobs.map { |j| j.path.length } }.max

  entries = programs.map { |p| render_program_entry(p, name_width, path_width) }
  input   = entries.join("\0")

  fzf = [
    "fzf",
    "--read0", "--ansi", "--no-multi",
    "--prompt=program> ",
    "--height=100%", "--reverse",
    "--no-sort",
    "--color=bg+:-1,hl+:reverse",
  ]

  output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: input)
  return nil unless status.success?
  return nil if output.strip.empty?

  first_line = output.lines.first.to_s
  programs.find { |p| first_line.include?(" #{p.name} ") || first_line.rstrip.end_with?(p.name) || first_line.include?(p.name) }
end

.pick_renderer(cfg) ⇒ Object

Pick the first available markdown renderer.



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/twin/picker.rb', line 148

def pick_renderer(cfg)
  if which("apex")
    args = ["--plugins", "-t", "terminal256"]
    args += ["--theme", cfg.apex_theme]                         if cfg.apex_theme
    args += ["--width", cfg.apex_width.to_s]                    if cfg.apex_width
    args += ["--code-highlight", cfg.apex_code_highlight]       if cfg.apex_code_highlight
    args += ["--code-highlight-theme", cfg.apex_code_highlight_theme] if cfg.apex_code_highlight_theme
    (["apex", '"$F"'] + args).join(" ") + " 2>/dev/null"
  elsif which("glow")
    %(glow -s dark "$F" 2>/dev/null)
  elsif which("bat")
    %(bat --color=always --language=markdown --style=plain "$F" 2>/dev/null)
  else
    %(cat "$F")
  end
end

.render_program_entry(program, name_width, path_width) ⇒ Object

── Helpers ───────────────────────────────────────────────────────────────



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/twin/picker.rb', line 173

def render_program_entry(program, name_width, path_width)
  icon      = colorize(program.status, STATUS_ICONS[program.status] || "?")
  sync_file = File.basename(program.sync_file.to_s)
  count     = program.active_jobs.size
  total     = program.jobs.size
  header    = "#{icon}  #{bold(program.name.ljust(name_width))}    " \
              "#{dim("(#{count}/#{total})")}    #{dim("[#{sync_file}]")}"

  body = program.jobs.map do |j|
    icon  = STATUS_ICONS[j.status] || "?"
    delta = format_delta(j.source_mtime, j.target_mtime)
    line  = "    #{icon}  #{j.path.ljust(path_width)}  #{delta}"
    colorize(j.status, line)
  end

  ([header] + body).join("\n")
end

.shellesc(s) ⇒ Object



205
206
207
# File 'lib/twin/picker.rb', line 205

def shellesc(s)
  "'" + s.to_s.gsub("'", %q['\\''])  + "'"
end

.which(cmd) ⇒ Object



165
166
167
168
169
# File 'lib/twin/picker.rb', line 165

def which(cmd)
  ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
    File.executable?(File.join(dir, cmd))
  end
end

.write_compact_previews(program, jobs) ⇒ Object

Write per-job compact-preview markdown to tempfiles. Returns => path.



122
123
124
125
126
127
128
129
130
131
132
# File 'lib/twin/picker.rb', line 122

def write_compact_previews(program, jobs)
  result = {}
  jobs.each_with_index do |job, i|
    compact = Preview.extract_compact(program.sync_file, job.path)
    f = Tempfile.new(["twin-#{i}-", ".md"])
    f.write(compact)
    f.close
    result[i] = f.path
  end
  result
end