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: "✗", unreachable: "?", 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 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
- .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
43 |
# File 'lib/twin/picker.rb', line 43 def bold(text) = "#{BOLD}#{text}#{RESET}" |
.build_apex_preview_cmd(tempfiles, cfg) ⇒ Object
Returns [preview_cmd, mapfile_path]; the caller unlinks the mapfile.
140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/twin/picker.rb', line 140 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
41 |
# File 'lib/twin/picker.rb', line 41 def colorize(status, text) = "#{STATUS_COLORS[status] || ''}#{text}#{RESET}" |
.format_delta(sm, tm) ⇒ Object
197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/twin/picker.rb', line 197 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.
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 123 124 |
# File 'lib/twin/picker.rb', line 84 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.
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 76 77 |
# File 'lib/twin/picker.rb', line 49 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.
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/twin/picker.rb', line 154 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 ───────────────────────────────────────────────────────────────
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/twin/picker.rb', line 179 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
211 212 213 |
# File 'lib/twin/picker.rb', line 211 def shellesc(s) "'" + s.to_s.gsub("'", %q['\\'']) + "'" end |
.which(cmd) ⇒ Object
171 172 173 174 175 |
# File 'lib/twin/picker.rb', line 171 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.
127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/twin/picker.rb', line 127 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 |