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

Returns [preview_cmd, mapfile_path]; the caller unlinks the mapfile.



138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/twin/picker.rb', line 138

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

  render_cmd = pick_renderer(cfg)

  cmd = %(F=$(awk -v id={1} -F'\\t' '$1==id {print $2}' #{mapfile.path}); ) +
        %([ -n "$F" ] && #{render_cmd})
  [cmd, mapfile.path]
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



195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/twin/picker.rb', line 195

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.



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
120
121
122
# File 'lib/twin/picker.rb', line 82

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)
  preview_cmd, mapfile = build_apex_preview_cmd(tempfiles, cfg)

  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

  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 }
  File.unlink(mapfile) rescue nil if mapfile
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
73
74
75
# 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.each_with_index.map do |p, i|
    "#{i}\t#{render_program_entry(p, name_width, path_width)}"
  end
  input = entries.join("\0")

  fzf = [
    "fzf",
    "--read0", "--ansi", "--no-multi",
    "--delimiter=\t", "--with-nth=2..",
    "--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?

  idx = output.split("\t", 2).first
  programs[idx.to_i] if idx&.match?(/\A\d+\z/)
end

.pick_renderer(cfg) ⇒ Object

Pick the first available markdown renderer.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/twin/picker.rb', line 152

def pick_renderer(cfg)
  if which("apex")
    args = ["--plugins", "-t", "terminal256"]
    args += ["--theme", shellesc(cfg.apex_theme)]                         if cfg.apex_theme
    args += ["--width", shellesc(cfg.apex_width)]                         if cfg.apex_width
    args += ["--code-highlight", shellesc(cfg.apex_code_highlight)]       if cfg.apex_code_highlight
    args += ["--code-highlight-theme", shellesc(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 ───────────────────────────────────────────────────────────────



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/twin/picker.rb', line 177

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



209
210
211
# File 'lib/twin/picker.rb', line 209

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

.which(cmd) ⇒ Object



169
170
171
172
173
# File 'lib/twin/picker.rb', line 169

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.



125
126
127
128
129
130
131
132
133
134
135
# File 'lib/twin/picker.rb', line 125

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