Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ TV comes with 30+ built-in channels. Use `:Tv` to see all available channels, or

Tab completion works: `:Tv <Tab>`

### Ad-hoc Channels

`pick(entries, opts)` lets Neovim integrations supply entries to a Television
ad-hoc channel.

- `entries` is the list of strings Television searches.
- `opts.args` contains optional Television CLI arguments; `pick` manages
`--source-command` and `--expect`.
- `opts.handlers` is a required map from keys such as `<CR>` to `tv.Handler`
functions. Each handler receives the selected entries and current `tv.Config`.

## Configuration

By default, tv.nvim passes no extra command-line arguments to `tv`: your television configuration (status bar, preview
Expand Down Expand Up @@ -265,6 +276,39 @@ Here's a more comprehensive configuration example demonstrating the plugin's cap
auto_open = true, -- automatically open quickfix window after populating
},
})

-- ad-hoc channel: browse listed Neovim buffers
vim.keymap.set("n", "<leader>fb", function()
local buffers = {}
for _, buffer in ipairs(vim.fn.getbufinfo({ buflisted = 1 })) do
if buffer.name ~= "" and vim.fn.filereadable(buffer.name) == 1 then
-- encode the buffer number, display name, and file path as tab-separated fields
table.insert(buffers, ("%d\t%s\t%s"):format(
buffer.bufnr,
vim.fn.fnamemodify(buffer.name, ":~:."),
vim.fn.shellescape(buffer.name)
))
end
end

require("tv").pick(buffers, {
args = {
"--input-header", "Buffers", -- label the Television input
"--source-display", "{split:\\t:1}", -- show the buffer name
"--source-output", "{split:\\t:0}", -- return the buffer number
"--preview-command", "bat -n --color=always {split:\\t:2}", -- preview the file with bat
},
handlers = {
["<CR>"] = function(entries)
local bufnr = tonumber(entries[1])
if bufnr then
-- switch to the buffer number returned by Television
vim.api.nvim_set_current_buf(bufnr)
end
end,
},
})
end, { desc = "Find buffers" }) -- describe the Neovim mapping
end,
},
```
Expand Down
135 changes: 135 additions & 0 deletions lua/tv/channels.lua
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,141 @@ function M.launch(channel_name, prompt_input)
launch_channel(channel_name, channel_config.handlers, prompt_input)
end

---@class tv.PickOptions
---@field args? string[]
---@field handlers table<string, tv.Handler>

local function selects_automatically(args, entry_count)
for _, arg in ipairs(args) do
if arg == "--take-1" or arg == "--take-1-fast" or (arg == "--select-1" and entry_count == 1) then
return true
end
end
return false
end

local function launch_picker(entries, opts)
local source_path = vim.fn.tempname()
if vim.fn.writefile(entries, source_path) ~= 0 then
vim.fn.delete(source_path)
vim.notify("Failed to write ad-hoc channel entries", vim.log.levels.ERROR, { title = "tv.nvim" })
return
end

local args = opts.args or {}
local cmd = { config.current.tv_binary }
vim.list_extend(cmd, args)

local read_command = vim.fn.has("win32") == 1 and "type" or "cat"
vim.list_extend(cmd, { "--source-command", read_command .. " " .. vim.fn.shellescape(source_path) })

local handlers_by_key = {}
local expect_keys = {}
for nvim_key, handler in pairs(opts.handlers) do
local tv_key = utils.convert_keybinding_to_tv_format(nvim_key)
if tv_key then
handlers_by_key[tv_key] = handler
table.insert(expect_keys, tv_key)
end
end

table.sort(expect_keys)
local has_expect = not selects_automatically(args, #entries) and #expect_keys > 0
if has_expect then
vim.list_extend(cmd, { "--expect", table.concat(expect_keys, ";") })
end

local tv_window = window.create()
local tv_buffer = vim.api.nvim_win_get_buf(tv_window)
local error_output = {}

local function finish()
vim.fn.delete(source_path)
pcall(vim.api.nvim_win_close, tv_window, true)
end

local job = vim.fn.jobstart(cmd, {
on_stderr = function(_, data)
if data then
for _, line in ipairs(data) do
if line ~= "" then
table.insert(error_output, line)
end
end
end
end,
on_exit = function(_, exit_code)
local output = vim.api.nvim_buf_is_valid(tv_buffer) and vim.api.nvim_buf_get_lines(tv_buffer, 0, -1, false) or {}
finish()

if exit_code ~= 0 then
local error_msg = "TV exited with code " .. exit_code
if #error_output > 0 then
error_msg = error_msg .. ":\n" .. table.concat(error_output, "\n")
end
vim.notify(error_msg, vim.log.levels.ERROR, { title = "tv.nvim" })
return
end

if #error_output > 0 then
vim.notify(table.concat(error_output, "\n"), vim.log.levels.WARN, { title = "tv.nvim" })
end

local handler
local start_idx = 1
if has_expect then
handler = handlers_by_key[vim.trim(output[1] or "")]
start_idx = 2
else
handler = handlers_by_key.enter
end

local selected = {}
for i = start_idx, #output do
if output[i] ~= "" then
table.insert(selected, output[i])
end
end

if handler and #selected > 0 then
handler(selected, config.current)
end
end,
term = true,
})

if job <= 0 then
finish()
vim.notify("Failed to start TV", vim.log.levels.ERROR, { title = "tv.nvim" })
return
end

vim.cmd("startinsert")
end

---Open a Television ad-hoc channel for the given entries
---@param entries string[]
---@param opts tv.PickOptions
function M.pick(entries, opts)
vim.validate({
entries = { entries, "table" },
opts = { opts, "table" },
})
vim.validate({
args = { opts.args, "table", true },
handlers = { opts.handlers, "table" },
})

if #entries == 0 then
return
end
if vim.tbl_isempty(opts.handlers) then
error("opts.handlers must not be empty")
end

launch_picker(entries, opts)
end

function M.select()
local handle = io.popen(config.current.tv_binary .. " list-channels 2>/dev/null")
if not handle then
Expand Down
2 changes: 2 additions & 0 deletions lua/tv/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ local M = {}
---@class tv.Module
---@field tv_channel fun(channel: string, prompt_input?: string): nil
---@field tv_channels fun(): nil
---@field pick fun(entries: string[], opts: tv.PickOptions): nil
---@field tv_files fun(prompt_input?: string): nil
---@field tv_text fun(prompt_input?: string): nil
---@field setup fun(opts?: tv.Config): nil
Expand All @@ -28,6 +29,7 @@ local M = {}

M.tv_channel = channels.launch
M.tv_channels = channels.select
M.pick = channels.pick

M.handlers = handlers
M.config = config.current
Expand Down
89 changes: 89 additions & 0 deletions tests/tv/tv_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,95 @@ describe("tv.nvim", function()
end)
end)

describe("ad-hoc channels", function()
local original = {}
local state

before_each(function()
state = {}
original.jobstart = vim.fn.jobstart
original.tempname = vim.fn.tempname

vim.fn.tempname = function()
state.source_path = original.tempname()
return state.source_path
end
vim.fn.jobstart = function(command, options)
state.job = { command = vim.deepcopy(command), options = options }
state.window = vim.api.nvim_get_current_win()
state.buffer = vim.api.nvim_get_current_buf()
return 1
end
end)

after_each(function()
vim.fn.jobstart = original.jobstart
vim.fn.tempname = original.tempname

if state.window and vim.api.nvim_win_is_valid(state.window) then
vim.api.nvim_win_close(state.window, true)
end
if state.buffer and vim.api.nvim_buf_is_valid(state.buffer) then
vim.api.nvim_buf_delete(state.buffer, { force = true })
end
if state.source_path then
vim.fn.delete(state.source_path)
end
end)

it("passes arguments and routes exact selections", function()
local received
tv.pick({ "1\tFirst", "2\tSecond" }, {
args = { "--source-display", "{split:\\t:1}" },
handlers = {
["<CR>"] = function() end,
["<C-s>"] = function(entries)
received = entries
end,
},
})

local read_command = vim.fn.has("win32") == 1 and "type" or "cat"
assert.are.same({
"tv",
"--source-display",
"{split:\\t:1}",
"--source-command",
read_command .. " " .. vim.fn.shellescape(state.source_path),
"--expect",
"ctrl-s;enter",
}, state.job.command)
vim.api.nvim_buf_set_lines(state.buffer, 0, -1, false, { "ctrl-s", " first ", "second" })
state.job.options.on_exit(1, 0)

assert.are.same({ " first ", "second" }, received)
assert.are.equal(0, vim.fn.filereadable(state.source_path))
end)

it("does not mistake an automatic selection for a confirmation key", function()
local received
local ctrl_s_called = false
tv.pick({ "ctrl-s" }, {
args = { "--take-1" },
handlers = {
["<CR>"] = function(entries)
received = entries
end,
["<C-s>"] = function()
ctrl_s_called = true
end,
},
})

assert.is_false(vim.tbl_contains(state.job.command, "--expect"))
vim.api.nvim_buf_set_lines(state.buffer, 0, -1, false, { "ctrl-s" })
state.job.options.on_exit(1, 0)

assert.are.same({ "ctrl-s" }, received)
assert.is_false(ctrl_s_called)
end)
end)

describe("_convert_keybinding_to_tv_format", function()
local convert = tv._convert_keybinding_to_tv_format

Expand Down