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
- .bold(text) ⇒ Object
- .build_apex_preview_cmd(tempfiles, cfg) ⇒ Object
- .colorize(status, text) ⇒ Object
- .dim(text) ⇒ Object
- .format_delta(sm, tm) ⇒ Object
-
.pick_paths(program, cfg) ⇒ Object
Multi-select over the jobs of one program.
-
.pick_program(programs) ⇒ Object
Multi-line entries (NUL-separated).
-
.pick_renderer(cfg) ⇒ Object
Pick the first available markdown renderer.
-
.render_program_entry(program, name_width, path_width) ⇒ Object
── Helpers ───────────────────────────────────────────────────────────────.
- .shellesc(s) ⇒ Object
- .which(cmd) ⇒ Object
-
.write_compact_previews(program, jobs) ⇒ Object
Write per-job compact-preview markdown to tempfiles.
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}" |
.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 |