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:        "",
  # Directory jobs in the picker: their mtimes prove nothing, and the
  # dry-run that would prove something is too slow for a picker to open
  # with. No claim instead of a wrong one — `twin status` has the verdict.
  unverified:     "",
  missing_target: "!",
  missing_source: "!",
  both_missing:   "",
  unreachable:    "?",
  disabled:       "·",
}.freeze
STATUS_COLORS =
{
  source_newer:   "\e[33m",   # yellow
  target_newer:   "\e[36m",   # cyan
  in_sync:        "\e[32m",   # green
  unverified:     "\e[2m",    # dim
  missing_target: "\e[31m",   # red
  missing_source: "\e[31m",   # red
  both_missing:   "\e[31m",   # red
  unreachable:    "\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



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

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

.build_apex_preview_cmd(tempfiles, cfg) ⇒ Object

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



144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/twin/picker.rb', line 144

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



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

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

.dim(text) ⇒ Object



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

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

.format_delta(sm, tm) ⇒ Object



206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/twin/picker.rb', line 206

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

.job_delta(job) ⇒ Object

A directory's mtime delta would mislead (it moves on every sync), so directory jobs show none.



202
203
204
# File 'lib/twin/picker.rb', line 202

def job_delta(job)
  job.directory ? "" : format_delta(job.source_mtime, job.target_mtime)
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.



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
123
124
125
126
127
128
# File 'lib/twin/picker.rb', line 89

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] || "?"
    line  = "#{icon}  #{j.path.ljust(path_width)}  #{job_delta(j)}"
    "#{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.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/twin/picker.rb', line 54

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.



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/twin/picker.rb', line 158

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 ───────────────────────────────────────────────────────────────



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/twin/picker.rb', line 183

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] || "?"
    line  = "    #{icon}  #{j.path.ljust(path_width)}  #{job_delta(j)}"
    colorize(j.status, line)
  end

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

.shellesc(s) ⇒ Object



220
221
222
# File 'lib/twin/picker.rb', line 220

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

.which(cmd) ⇒ Object



175
176
177
178
179
# File 'lib/twin/picker.rb', line 175

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.



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/twin/picker.rb', line 131

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