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
Returns [preview_cmd, mapfile_path]; the caller unlinks the mapfile.
- .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
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}" |
.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 |