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
- .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
-
.job_delta(job) ⇒ Object
A directory's mtime delta would mislead (it moves on every sync), so directory jobs show none.
-
.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
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}" |
.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 |