├── .gitignore ├── .stylua.toml ├── Makefile ├── .github ├── workflows │ ├── release.yml │ ├── docs.yml │ └── lint-test.yml └── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── tests ├── minimal_init.lua └── tv │ └── tv_spec.lua ├── lua └── tv │ ├── window.lua │ ├── utils.lua │ ├── init.lua │ ├── config.lua │ ├── channels.lua │ └── handlers.lua ├── LICENSE ├── plugin └── tv.lua ├── README.md └── doc └── tv.nvim-docs.txt /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/plenary.nvim 2 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 2 5 | quote_style = "AutoPreferDouble" 6 | no_call_parentheses = false 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS_INIT=tests/minimal_init.lua 2 | TESTS_DIR=tests/ 3 | 4 | .PHONY: test 5 | 6 | test: 7 | @nvim \ 8 | --headless \ 9 | --noplugin \ 10 | -u ${TESTS_INIT} \ 11 | -c "PlenaryBustedDirectory ${TESTS_DIR} { minimal_init = '${TESTS_INIT}' }" 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "release" 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | luarocks-upload: 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: LuaRocks Upload 12 | uses: nvim-neorocks/luarocks-tag-release@v7 13 | env: 14 | LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} 15 | -------------------------------------------------------------------------------- /tests/minimal_init.lua: -------------------------------------------------------------------------------- 1 | local plenary_dir = os.getenv("PLENARY_DIR") or "/tmp/plenary.nvim" 2 | local is_not_a_directory = vim.fn.isdirectory(plenary_dir) == 0 3 | if is_not_a_directory then 4 | vim.fn.system({ "git", "clone", "https://github.com/nvim-lua/plenary.nvim", plenary_dir }) 5 | end 6 | 7 | vim.opt.rtp:append(".") 8 | vim.opt.rtp:append(plenary_dir) 9 | 10 | vim.cmd("runtime plugin/plenary.vim") 11 | require("plenary.busted") 12 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: docs 6 | permissions: 7 | contents: write 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: panvimdoc 14 | uses: kdheepak/panvimdoc@main 15 | with: 16 | vimdoc: tv.nvim-docs 17 | version: "Neovim >= 0.8.0" 18 | demojify: true 19 | treesitter: true 20 | - name: Push changes 21 | uses: stefanzweifel/git-auto-commit-action@v6 22 | with: 23 | commit_message: "chore(ci): auto-generate vimdoc" 24 | commit_user_name: "github-actions[bot]" 25 | commit_user_email: "github-actions[bot]@users.noreply.github.com" 26 | commit_author: "github-actions[bot] " 27 | -------------------------------------------------------------------------------- /lua/tv/window.lua: -------------------------------------------------------------------------------- 1 | local config = require("tv.config") 2 | 3 | local M = {} 4 | 5 | function M.create(channel) 6 | local editor_height = vim.o.lines 7 | local editor_width = vim.o.columns 8 | local window_config = config.get_window_config(channel or "default") 9 | 10 | local tv_height = math.floor(window_config.height * editor_height) 11 | local tv_width = math.floor(window_config.width * editor_width) 12 | local row = (editor_height - tv_height) / 2 13 | local col = (editor_width - tv_width) / 2 14 | 15 | local buffer = vim.api.nvim_create_buf(false, true) 16 | vim.api.nvim_open_win(buffer, true, { 17 | relative = "editor", 18 | width = tv_width, 19 | height = tv_height, 20 | row = row, 21 | col = col, 22 | border = window_config.border, 23 | title = window_config.title, 24 | title_pos = window_config.title_pos, 25 | }) 26 | end 27 | 28 | return M 29 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: [push, pull_request] 3 | name: lint-test 4 | 5 | jobs: 6 | stylua: 7 | name: stylua 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: JohnnyMorganz/stylua-action@v4 12 | with: 13 | version: latest 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | args: --color always --check lua 16 | 17 | test: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest, macos-latest, windows-latest] 22 | nvim-versions: ['stable', 'nightly'] 23 | name: test 24 | steps: 25 | - name: checkout 26 | uses: actions/checkout@v4 27 | 28 | - uses: rhysd/action-setup-vim@v1 29 | with: 30 | neovim: true 31 | version: ${{ matrix.nvim-versions }} 32 | 33 | - name: run tests 34 | run: make test 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ellison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /plugin/tv.lua: -------------------------------------------------------------------------------- 1 | if vim.g.loaded_tv_nvim == 1 then 2 | return 3 | end 4 | vim.g.loaded_tv_nvim = 1 5 | 6 | vim.api.nvim_create_user_command("Tv", function(opts) 7 | local args = vim.trim(opts.args) 8 | 9 | if args == "" then 10 | require("tv").tv_channels() 11 | return 12 | end 13 | 14 | local parts = vim.split(args, "%s+", { trimempty = true }) 15 | local channel = parts[1] 16 | local prompt_input = #parts > 1 and table.concat(vim.list_slice(parts, 2), " ") or nil 17 | 18 | require("tv").tv_channel(channel, prompt_input) 19 | end, { 20 | desc = "Launch tv channel (usage: :Tv [channel] [query])", 21 | nargs = "*", 22 | complete = function(arg_lead, cmdline, _) 23 | local args = vim.split(vim.trim(cmdline:sub(4)), "%s+", { trimempty = true }) 24 | 25 | if #args <= 1 then 26 | local handle = io.popen("tv list-channels 2>/dev/null") 27 | if not handle then 28 | return {} 29 | end 30 | local result = handle:read("*a") 31 | handle:close() 32 | 33 | local channels = {} 34 | for channel in result:gmatch("[^\r\n]+") do 35 | if channel:match("^" .. vim.pesc(arg_lead)) then 36 | table.insert(channels, channel) 37 | end 38 | end 39 | return channels 40 | end 41 | 42 | return {} 43 | end, 44 | }) 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature 3 | title: "feature: " 4 | labels: [enhancement] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Did you check the docs? 9 | description: Make sure you read all the docs before submitting a feature request 10 | options: 11 | - label: I have read all the docs 12 | required: true 13 | - type: textarea 14 | validations: 15 | required: true 16 | attributes: 17 | label: Is your feature request related to a problem? Please describe. 18 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 19 | - type: textarea 20 | validations: 21 | required: true 22 | attributes: 23 | label: Describe the solution you'd like 24 | description: A clear and concise description of what you want to happen. 25 | - type: textarea 26 | validations: 27 | required: true 28 | attributes: 29 | label: Describe alternatives you've considered 30 | description: A clear and concise description of any alternative solutions or features you've considered. 31 | - type: textarea 32 | validations: 33 | required: false 34 | attributes: 35 | label: Additional context 36 | description: Add any other context or screenshots about the feature request here. 37 | -------------------------------------------------------------------------------- /lua/tv/utils.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---Convert Neovim keybinding notation to tv format 4 | ---@param keybinding string 5 | ---@return string? 6 | function M.convert_keybinding_to_tv_format(keybinding) 7 | if not keybinding then 8 | return nil 9 | end 10 | 11 | local special_keys = { 12 | [""] = "enter", 13 | [""] = "enter", 14 | [""] = "enter", 15 | [""] = "tab", 16 | [""] = "esc", 17 | [""] = "space", 18 | [""] = "backspace", 19 | [""] = "backspace", 20 | [""] = "delete", 21 | [""] = "delete", 22 | [""] = "up", 23 | [""] = "down", 24 | [""] = "left", 25 | [""] = "right", 26 | [""] = "home", 27 | [""] = "end", 28 | [""] = "page-up", 29 | [""] = "page-down", 30 | } 31 | 32 | for nvim_key, tv_key in pairs(special_keys) do 33 | if keybinding:lower() == nvim_key:lower() then 34 | return tv_key 35 | end 36 | end 37 | 38 | return keybinding 39 | :gsub("]+)>", "ctrl-%1") 40 | :gsub("]+)>", "alt-%1") 41 | :gsub("]+)>", "alt-%1") 42 | :gsub("]+)>", "shift-%1") 43 | :gsub("<([^>]+)>", "%1") 44 | :lower() 45 | end 46 | 47 | ---@param items table[] 48 | ---@param title string 49 | ---@param config tv.Config 50 | function M.populate_quickfix(items, title, config) 51 | vim.fn.setqflist({}, "r", { 52 | title = title or "TV", 53 | items = items, 54 | }) 55 | 56 | if config.quickfix.auto_open then 57 | vim.cmd("copen") 58 | end 59 | end 60 | 61 | return M 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug/issue 3 | title: "bug: " 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **Before** reporting an issue, make sure to read the documentation and search existing issues. Usage questions such as ***"How do I...?"*** belong in Discussions and will be closed. 10 | - type: checkboxes 11 | attributes: 12 | label: Did you check docs and existing issues? 13 | description: Make sure you checked all of the below before submitting an issue 14 | options: 15 | - label: I have read all the plugin docs 16 | required: true 17 | - label: I have searched the existing issues 18 | required: true 19 | - label: I have searched the existing issues of plugins related to this issue 20 | required: true 21 | - type: input 22 | attributes: 23 | label: "Neovim version (nvim -v)" 24 | placeholder: "0.8.0 commit db1b0ee3b30f" 25 | validations: 26 | required: true 27 | - type: input 28 | attributes: 29 | label: "Operating system/version" 30 | placeholder: "MacOS 11.5" 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Describe the bug 36 | description: A clear and concise description of what the bug is. Please include any related errors you see in Neovim. 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Steps To Reproduce 42 | description: Steps to reproduce the behavior. 43 | placeholder: | 44 | 1. 45 | 2. 46 | 3. 47 | validations: 48 | required: true 49 | - type: textarea 50 | attributes: 51 | label: Expected Behavior 52 | description: A concise description of what you expected to happen. 53 | validations: 54 | required: true 55 | -------------------------------------------------------------------------------- /lua/tv/init.lua: -------------------------------------------------------------------------------- 1 | local config = require("tv.config") 2 | local channels = require("tv.channels") 3 | local handlers = require("tv.handlers") 4 | local utils = require("tv.utils") 5 | 6 | local M = {} 7 | 8 | ---@class tv.Module 9 | ---@field tv_channel fun(channel: string, prompt_input?: string): nil 10 | ---@field tv_channels fun(): nil 11 | ---@field tv_files fun(prompt_input?: string): nil 12 | ---@field tv_text fun(prompt_input?: string): nil 13 | ---@field setup fun(opts?: tv.Config): nil 14 | ---@field handlers tv.HandlersModule 15 | 16 | ---@class tv.HandlersModule 17 | ---@field copy_to_clipboard tv.Handler 18 | ---@field insert_at_cursor tv.Handler 19 | ---@field insert_on_new_line tv.Handler 20 | ---@field open_as_files tv.Handler 21 | ---@field open_at_line tv.Handler 22 | ---@field open_in_split tv.Handler 23 | ---@field open_in_vsplit tv.Handler 24 | ---@field open_in_scratch tv.Handler 25 | ---@field send_to_quickfix tv.Handler 26 | ---@field show_in_select tv.Handler 27 | ---@field execute_shell_command fun(command_template: string): tv.Handler 28 | 29 | M.tv_channel = channels.launch 30 | M.tv_channels = channels.select 31 | 32 | M.handlers = handlers 33 | M.config = config.current 34 | 35 | local function setup_keybindings() 36 | if config.current.global_keybindings.channels then 37 | pcall(vim.keymap.del, "n", config.current.global_keybindings.channels) 38 | vim.keymap.set("n", config.current.global_keybindings.channels, M.tv_channels, { desc = "TV: Select channel" }) 39 | end 40 | 41 | if config.current.channels then 42 | for channel_name, channel_config in pairs(config.current.channels) do 43 | if channel_config.keybinding then 44 | pcall(vim.keymap.del, "n", channel_config.keybinding) 45 | local desc = "TV: " .. channel_name:gsub("-", " "):gsub("^%l", string.upper) 46 | vim.keymap.set("n", channel_config.keybinding, function() 47 | M.tv_channel(channel_name) 48 | end, { desc = desc }) 49 | end 50 | end 51 | end 52 | end 53 | 54 | ---@param opts? tv.Config 55 | function M.setup(opts) 56 | if opts then 57 | config.merge(opts) 58 | end 59 | config.initialize_channel_defaults() 60 | setup_keybindings() 61 | end 62 | 63 | M._convert_keybinding_to_tv_format = utils.convert_keybinding_to_tv_format 64 | 65 | ---@type tv.Module 66 | return M 67 | -------------------------------------------------------------------------------- /lua/tv/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local handlers = nil 4 | local function get_handlers() 5 | if not handlers then 6 | handlers = require("tv.handlers") 7 | end 8 | return handlers 9 | end 10 | 11 | local DEFAULT_TV_ARGS = { "--no-remote", "--no-status-bar" } 12 | 13 | M.defaults = { 14 | tv_binary = "tv", 15 | quickfix = { 16 | auto_open = true, 17 | }, 18 | window = { 19 | width = 0.8, 20 | height = 0.8, 21 | border = "none", 22 | title = " tv.nvim ", 23 | title_pos = "center", 24 | }, 25 | global_keybindings = { 26 | channels = "tv", 27 | }, 28 | channels = { 29 | files = { 30 | args = { "--no-remote", "--no-status-bar", "--preview-size", "70", "--layout", "portrait" }, 31 | keybinding = nil, 32 | handlers = { 33 | [""] = function(entries, config) 34 | return get_handlers().open_as_files(entries, config) 35 | end, 36 | [""] = function(entries, config) 37 | return get_handlers().send_to_quickfix(entries, config) 38 | end, 39 | }, 40 | }, 41 | text = { 42 | args = { "--no-remote", "--no-status-bar", "--preview-size", "70", "--layout", "portrait" }, 43 | keybinding = nil, 44 | handlers = { 45 | [""] = function(entries, config) 46 | return get_handlers().open_at_line(entries, config) 47 | end, 48 | [""] = function(entries, config) 49 | return get_handlers().send_to_quickfix(entries, config) 50 | end, 51 | }, 52 | }, 53 | }, 54 | } 55 | 56 | M.current = vim.deepcopy(M.defaults) 57 | 58 | local discovered_channels = nil 59 | local function discover_channels(tv_binary) 60 | if discovered_channels then 61 | return discovered_channels 62 | end 63 | 64 | local handle = io.popen(tv_binary .. " list-channels 2>/dev/null") 65 | if not handle then 66 | return {} 67 | end 68 | 69 | local result = handle:read("*a") 70 | handle:close() 71 | 72 | local channels = {} 73 | for channel in result:gmatch("[^\r\n]+") do 74 | if channel ~= "" then 75 | table.insert(channels, channel) 76 | end 77 | end 78 | 79 | discovered_channels = channels 80 | return channels 81 | end 82 | 83 | function M.initialize_channel_defaults() 84 | local channels = discover_channels(M.current.tv_binary) 85 | local defaults = { args = DEFAULT_TV_ARGS } 86 | 87 | for _, channel_name in ipairs(channels) do 88 | local user_config = M.current.channels[channel_name] 89 | if user_config then 90 | M.current.channels[channel_name] = vim.tbl_deep_extend("force", defaults, user_config) 91 | else 92 | M.current.channels[channel_name] = defaults 93 | end 94 | end 95 | end 96 | 97 | function M.get_window_config(channel) 98 | local base_config = M.current.window 99 | local channel_config = {} 100 | if M.current.channels[channel] and M.current.channels[channel].window then 101 | channel_config = M.current.channels[channel].window 102 | end 103 | return vim.tbl_deep_extend("force", base_config, channel_config) 104 | end 105 | 106 | function M.get_channel_config(channel) 107 | if not discovered_channels then 108 | M.initialize_channel_defaults() 109 | end 110 | 111 | if M.current.channels[channel] then 112 | return M.current.channels[channel] 113 | end 114 | 115 | return { args = DEFAULT_TV_ARGS } 116 | end 117 | 118 | function M.merge(user_config) 119 | M.current = vim.tbl_deep_extend("force", M.current, user_config or {}) 120 | end 121 | 122 | return M 123 | -------------------------------------------------------------------------------- /lua/tv/channels.lua: -------------------------------------------------------------------------------- 1 | local config = require("tv.config") 2 | local window = require("tv.window") 3 | local handlers = require("tv.handlers") 4 | local utils = require("tv.utils") 5 | 6 | local M = {} 7 | 8 | local function launch_channel(channel, handler_map, prompt_input) 9 | window.create(channel) 10 | local output = {} 11 | local error_output = {} 12 | 13 | local cmd = { config.current.tv_binary } 14 | local channel_config = config.get_channel_config(channel) 15 | vim.list_extend(cmd, channel_config.args) 16 | 17 | if handler_map and type(handler_map) == "table" then 18 | local expect_keys = {} 19 | for nvim_key, _ in pairs(handler_map) do 20 | local tv_key = utils.convert_keybinding_to_tv_format(nvim_key) 21 | if tv_key then 22 | table.insert(expect_keys, tv_key) 23 | end 24 | end 25 | 26 | if #expect_keys > 0 then 27 | vim.list_extend(cmd, { "--expect", table.concat(expect_keys, ";") }) 28 | end 29 | end 30 | 31 | vim.list_extend(cmd, { channel }) 32 | 33 | if prompt_input then 34 | vim.list_extend(cmd, { "-i" .. tostring(prompt_input) }) 35 | end 36 | 37 | vim.fn.jobstart(cmd, { 38 | on_stderr = function(_, data) 39 | if data then 40 | for _, line in ipairs(data) do 41 | if line ~= "" then 42 | table.insert(error_output, line) 43 | end 44 | end 45 | end 46 | end, 47 | on_exit = function(_, exit_code) 48 | output = vim.api.nvim_buf_get_lines(0, 0, -1, false) 49 | pcall(vim.api.nvim_win_close, 0, true) 50 | 51 | if exit_code ~= 0 then 52 | local error_msg = "TV exited with code " .. exit_code 53 | if #error_output > 0 then 54 | error_msg = error_msg .. ":\n" .. table.concat(error_output, "\n") 55 | end 56 | vim.notify(error_msg, vim.log.levels.ERROR, { title = "tv.nvim" }) 57 | return 58 | end 59 | 60 | if #error_output > 0 then 61 | vim.notify(table.concat(error_output, "\n"), vim.log.levels.WARN, { title = "tv.nvim" }) 62 | end 63 | 64 | local pressed_key = nil 65 | local start_idx = 1 66 | 67 | if handler_map and #output > 0 and output[1] ~= "" then 68 | for nvim_key, _ in pairs(handler_map) do 69 | local tv_key = utils.convert_keybinding_to_tv_format(nvim_key) 70 | if tv_key and output[1] == tv_key then 71 | pressed_key = nvim_key 72 | start_idx = 2 73 | break 74 | end 75 | end 76 | end 77 | 78 | local entries = {} 79 | for i = start_idx, #output do 80 | local line = vim.fn.trim(output[i]) 81 | if line ~= "" then 82 | table.insert(entries, line) 83 | end 84 | end 85 | 86 | if pressed_key and handler_map[pressed_key] then 87 | handler_map[pressed_key](entries, config.current) 88 | else 89 | handlers.open_as_files(entries, config.current) 90 | end 91 | end, 92 | term = true, 93 | }) 94 | vim.cmd("startinsert") 95 | end 96 | 97 | function M.launch(channel_name, prompt_input) 98 | if not channel_name or channel_name == "" then 99 | vim.notify("Channel name is required", vim.log.levels.ERROR) 100 | return 101 | end 102 | 103 | local channel_config = config.get_channel_config(channel_name) 104 | launch_channel(channel_name, channel_config.handlers, prompt_input) 105 | end 106 | 107 | function M.select() 108 | local handle = io.popen(config.current.tv_binary .. " list-channels 2>/dev/null") 109 | if not handle then 110 | vim.notify("Failed to get available channels", vim.log.levels.ERROR) 111 | return 112 | end 113 | 114 | local result = handle:read("*a") 115 | handle:close() 116 | 117 | local channels = {} 118 | for channel in result:gmatch("[^\r\n]+") do 119 | if channel ~= "" then 120 | table.insert(channels, channel) 121 | end 122 | end 123 | 124 | if #channels == 0 then 125 | vim.notify("No channels available", vim.log.levels.WARN) 126 | return 127 | end 128 | 129 | table.sort(channels, function(a, b) 130 | if a == "files" then 131 | return true 132 | elseif b == "files" then 133 | return false 134 | elseif a == "text" then 135 | return true 136 | elseif b == "text" then 137 | return false 138 | else 139 | return a < b 140 | end 141 | end) 142 | 143 | vim.ui.select(channels, { 144 | prompt = "Select TV channel:", 145 | format_item = function(item) 146 | local descriptions = { 147 | files = "🔍 Search and open files", 148 | text = "📝 Search text content", 149 | ["git-log"] = "📜 Browse git commit history", 150 | ["git-branch"] = "🌿 Switch git branches", 151 | ["git-repos"] = "📁 Browse git repositories", 152 | ["docker-images"] = "🐳 Browse docker images", 153 | ["bash-history"] = "💻 Search bash command history", 154 | ["zsh-history"] = "💻 Search zsh command history", 155 | ["fish-history"] = "💻 Search fish command history", 156 | ["k8s-pods"] = "☸️ Browse Kubernetes pods", 157 | ["k8s-services"] = "☸️ Browse Kubernetes services", 158 | ["k8s-deployments"] = "☸️ Browse Kubernetes deployments", 159 | ["aws-instances"] = "☁️ Browse AWS EC2 instances", 160 | ["aws-buckets"] = "☁️ Browse AWS S3 buckets", 161 | ["github-issues"] = "🐙 Browse GitHub issues", 162 | sesh = "🪢 Manage tmux sessions", 163 | dotfiles = "💼 Manage dotfiles", 164 | ["man-pages"] = "📖 Browse man pages", 165 | ["just-recipes"] = "📋 Browse justfile recipes", 166 | ["git-reflog"] = "🔄 Browse git reflog", 167 | alias = "🔤 Browse shell aliases", 168 | guix = "🛍️ Browse Guix packages", 169 | procs = "⚙️ Browse system processes", 170 | ["git-diff"] = "🆚 Browse git diffs", 171 | channels = "📡 Browse available TV channels", 172 | dirs = "📂 Browse directories", 173 | ["distrobox-list"] = "🐧 Browse Distrobox containers", 174 | env = "🌐 Browse environment variables", 175 | ["nu-history"] = "📜 Browse Nushell command history", 176 | tldr = "📚 Browse tldr pages", 177 | } 178 | local desc = descriptions[item] 179 | if desc then 180 | return desc 181 | else 182 | return item:gsub("-", " "):gsub("^%l", string.upper) 183 | end 184 | end, 185 | }, function(choice) 186 | if choice then 187 | M.launch(choice) 188 | end 189 | end) 190 | end 191 | 192 | return M 193 | -------------------------------------------------------------------------------- /tests/tv/tv_spec.lua: -------------------------------------------------------------------------------- 1 | local tv = require("tv") 2 | local tv_config = require("tv.config") 3 | 4 | describe("tv.nvim", function() 5 | before_each(function() 6 | -- Reset config to defaults before each test 7 | -- We need to reset config.current directly since tv.config is a reference to it 8 | for k in pairs(tv_config.current) do 9 | tv_config.current[k] = nil 10 | end 11 | for k, v in pairs(tv_config.defaults) do 12 | tv_config.current[k] = vim.deepcopy(v) 13 | end 14 | end) 15 | 16 | describe("setup", function() 17 | it("works with default config", function() 18 | tv.setup() 19 | assert.are.equal("tv", tv.config.tv_binary) 20 | assert.are.equal(0.8, tv.config.window.width) 21 | assert.is_nil(tv.config.channels.files.keybinding) 22 | assert.are.equal("tv", tv.config.global_keybindings.channels) 23 | end) 24 | 25 | it("works without calling setup (defaults available)", function() 26 | -- Config should have defaults even without calling setup 27 | assert.are.equal("tv", tv.config.tv_binary) 28 | assert.are.equal(0.8, tv.config.window.width) 29 | assert.is_nil(tv.config.channels.files.keybinding) 30 | end) 31 | 32 | it("merges custom config with defaults", function() 33 | tv.setup({ 34 | tv_binary = "custom-tv", 35 | window = { 36 | width = 0.9, 37 | border = "single", 38 | }, 39 | channels = { 40 | files = { 41 | args = { "--custom-arg" }, 42 | }, 43 | }, 44 | }) 45 | 46 | -- Access config.current directly since tv.config reference may break after merge 47 | assert.are.equal("custom-tv", tv_config.current.tv_binary) 48 | assert.are.equal(0.9, tv_config.current.window.width) 49 | assert.are.equal(0.8, tv_config.current.window.height) -- should keep default 50 | assert.are.equal("single", tv_config.current.window.border) 51 | assert.are.same({ "--custom-arg" }, tv_config.current.channels.files.args) 52 | assert.is_nil(tv_config.current.channels.files.keybinding) -- should keep default (nil) 53 | end) 54 | 55 | it("allows per-channel window configuration", function() 56 | tv.setup({ 57 | channels = { 58 | files = { 59 | window = { 60 | width = 0.9, 61 | title = " Files ", 62 | border = "rounded", 63 | }, 64 | }, 65 | text = { 66 | window = { 67 | width = 0.7, 68 | title = " Text Search ", 69 | }, 70 | }, 71 | }, 72 | }) 73 | 74 | -- Files channel should have custom window settings 75 | assert.are.equal(0.9, tv_config.current.channels.files.window.width) 76 | assert.are.equal(" Files ", tv_config.current.channels.files.window.title) 77 | assert.are.equal("rounded", tv_config.current.channels.files.window.border) 78 | 79 | -- Text channel should have custom window settings 80 | assert.are.equal(0.7, tv_config.current.channels.text.window.width) 81 | assert.are.equal(" Text Search ", tv_config.current.channels.text.window.title) 82 | 83 | -- Global defaults should remain 84 | assert.are.equal(0.8, tv_config.current.window.width) 85 | assert.are.equal("none", tv_config.current.window.border) 86 | end) 87 | 88 | it("allows disabling keybindings", function() 89 | tv.setup({ 90 | global_keybindings = { 91 | channels = false, 92 | }, 93 | channels = { 94 | files = { 95 | keybinding = false, 96 | }, 97 | text = { 98 | keybinding = false, 99 | }, 100 | }, 101 | }) 102 | 103 | assert.are.equal(false, tv_config.current.global_keybindings.channels) 104 | assert.are.equal(false, tv_config.current.channels.files.keybinding) 105 | assert.are.equal(false, tv_config.current.channels.text.keybinding) 106 | end) 107 | end) 108 | 109 | describe("configuration", function() 110 | it("has expected default values", function() 111 | assert.are.equal("tv", tv.config.tv_binary) 112 | assert.are.equal(0.8, tv.config.window.width) 113 | assert.are.equal(0.8, tv.config.window.height) 114 | assert.are.equal("none", tv.config.window.border) 115 | assert.are.equal(" tv.nvim ", tv.config.window.title) 116 | assert.are.equal("center", tv.config.window.title_pos) 117 | end) 118 | 119 | it("has expected default quickfix config", function() 120 | assert.are.equal(true, tv.config.quickfix.auto_open) 121 | end) 122 | end) 123 | 124 | describe("_convert_keybinding_to_tv_format", function() 125 | local convert = tv._convert_keybinding_to_tv_format 126 | 127 | describe("control key combinations", function() 128 | it("converts to ctrl-q", function() 129 | assert.are.equal("ctrl-q", convert("")) 130 | end) 131 | 132 | it("converts uppercase to ctrl-q", function() 133 | assert.are.equal("ctrl-q", convert("")) 134 | end) 135 | end) 136 | 137 | describe("alt key combinations", function() 138 | it("converts to alt-q", function() 139 | assert.are.equal("alt-q", convert("")) 140 | end) 141 | 142 | it("converts to alt-q (meta as alt)", function() 143 | assert.are.equal("alt-q", convert("")) 144 | end) 145 | end) 146 | 147 | describe("shift key combinations", function() 148 | it("converts to shift-f", function() 149 | assert.are.equal("shift-f", convert("")) 150 | end) 151 | end) 152 | 153 | describe("special keys", function() 154 | it("converts to enter", function() 155 | assert.are.equal("enter", convert("")) 156 | end) 157 | 158 | it("converts to esc", function() 159 | assert.are.equal("esc", convert("")) 160 | end) 161 | 162 | it("converts to tab", function() 163 | assert.are.equal("tab", convert("")) 164 | end) 165 | 166 | it("converts to space", function() 167 | assert.are.equal("space", convert("")) 168 | end) 169 | end) 170 | 171 | describe("edge cases", function() 172 | it("returns nil for nil input", function() 173 | assert.is_nil(convert(nil)) 174 | end) 175 | 176 | it("converts plain text without brackets", function() 177 | assert.are.equal("ctrl-q", convert("ctrl-q")) 178 | end) 179 | 180 | it("handles case conversion", function() 181 | assert.are.equal("ctrl-q", convert("")) 182 | end) 183 | end) 184 | 185 | describe("complex combinations", function() 186 | it("handles multiple modifier keys in sequence", function() 187 | -- While uncommon, test that the function handles text with multiple patterns 188 | local input = " and " 189 | local expected = "ctrl-a and alt-b" 190 | assert.are.equal(expected, convert(input)) 191 | end) 192 | end) 193 | end) 194 | end) 195 | -------------------------------------------------------------------------------- /lua/tv/handlers.lua: -------------------------------------------------------------------------------- 1 | local utils = require("tv.utils") 2 | 3 | local M = {} 4 | 5 | ---@class tv.Config 6 | ---@field tv_binary string 7 | ---@field quickfix { auto_open: boolean } 8 | ---@field window { width: number, height: number, border: string, title: string, title_pos: string } 9 | ---@field global_keybindings { channels: string } 10 | ---@field channels table 11 | 12 | ---@class tv.ChannelConfig 13 | ---@field keybinding? string 14 | ---@field args? string[] 15 | ---@field window? { width?: number, height?: number, border?: string, title?: string, title_pos?: string } 16 | ---@field handlers? table 17 | 18 | ---@alias tv.Handler fun(entries: string[], config: tv.Config) 19 | 20 | ---Copy entries to system clipboard 21 | ---@param entries string[] 22 | ---@param _ tv.Config 23 | function M.copy_to_clipboard(entries, _) 24 | if #entries == 0 then 25 | return 26 | end 27 | local text = table.concat(entries, "\n") 28 | vim.fn.setreg("+", text) 29 | vim.notify(string.format("Copied %d item(s) to clipboard", #entries), vim.log.levels.INFO, { title = "tv.nvim" }) 30 | end 31 | 32 | ---Insert entries at cursor position 33 | ---@param entries string[] 34 | ---@param _ tv.Config 35 | function M.insert_at_cursor(entries, _) 36 | if #entries == 0 then 37 | return 38 | end 39 | local row, col = unpack(vim.api.nvim_win_get_cursor(0)) 40 | local line = vim.api.nvim_get_current_line() 41 | 42 | -- Insert first entry at cursor position 43 | local new_line = line:sub(1, col) .. entries[1] .. line:sub(col + 1) 44 | vim.api.nvim_set_current_line(new_line) 45 | 46 | -- Insert remaining entries as new lines 47 | if #entries > 1 then 48 | local remaining = vim.list_slice(entries, 2) 49 | vim.api.nvim_buf_set_lines(0, row, row, false, remaining) 50 | end 51 | end 52 | 53 | ---Insert entries on new lines after cursor 54 | ---@param entries string[] 55 | ---@param _ tv.Config 56 | function M.insert_on_new_line(entries, _) 57 | if #entries == 0 then 58 | return 59 | end 60 | local row = vim.api.nvim_win_get_cursor(0)[1] 61 | vim.api.nvim_buf_set_lines(0, row, row, false, entries) 62 | end 63 | 64 | ---Open entries as files 65 | ---@param entries string[] 66 | ---@param _ tv.Config 67 | function M.open_as_files(entries, _) 68 | for _, entry in ipairs(entries) do 69 | local file = vim.fn.trim(entry) 70 | if vim.fn.filereadable(file) == 1 then 71 | vim.cmd("edit " .. vim.fn.fnameescape(file)) 72 | end 73 | end 74 | end 75 | 76 | ---Open files at line numbers (file:line:col format) 77 | ---@param entries string[] 78 | ---@param _ tv.Config 79 | function M.open_at_line(entries, _) 80 | for _, entry in ipairs(entries) do 81 | local parts = vim.split(entry, ":", { plain = true }) 82 | if #parts >= 2 then 83 | local filename = vim.fn.trim(parts[1]) 84 | if vim.fn.filereadable(filename) == 1 then 85 | local lnum = tonumber(vim.fn.trim(parts[2])) or 1 86 | local col = #parts >= 3 and tonumber(vim.fn.trim(parts[3])) or nil 87 | 88 | vim.cmd("edit +" .. lnum .. " " .. vim.fn.fnameescape(filename)) 89 | 90 | if col then 91 | vim.api.nvim_win_set_cursor(0, { lnum, col - 1 }) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | 98 | ---Open entries as files in vertical splits 99 | ---@param entries string[] 100 | ---@param _ tv.Config 101 | function M.open_in_vsplit(entries, _) 102 | for _, entry in ipairs(entries) do 103 | local file = vim.fn.trim(entry) 104 | if vim.fn.filereadable(file) == 1 then 105 | vim.cmd("vsplit " .. vim.fn.fnameescape(file)) 106 | end 107 | end 108 | end 109 | 110 | ---Open entries as files in horizontal splits 111 | ---@param entries string[] 112 | ---@param _ tv.Config 113 | function M.open_in_split(entries, _) 114 | for _, entry in ipairs(entries) do 115 | local file = vim.fn.trim(entry) 116 | if vim.fn.filereadable(file) == 1 then 117 | vim.cmd("split " .. vim.fn.fnameescape(file)) 118 | end 119 | end 120 | end 121 | 122 | ---Open entries in a scratch buffer 123 | ---@param entries string[] 124 | ---@param _ tv.Config 125 | function M.open_in_scratch(entries, _) 126 | if #entries == 0 then 127 | return 128 | end 129 | vim.cmd("enew") 130 | vim.bo.buftype = "nofile" 131 | vim.bo.bufhidden = "wipe" 132 | vim.bo.swapfile = false 133 | vim.api.nvim_buf_set_lines(0, 0, -1, false, entries) 134 | end 135 | 136 | ---Send entries to quickfix (auto-detects file formats) 137 | ---@param entries string[] 138 | ---@param config tv.Config 139 | function M.send_to_quickfix(entries, config) 140 | local qf_items = {} 141 | for _, entry in ipairs(entries) do 142 | local trimmed = vim.fn.trim(entry) 143 | local parts = vim.split(trimmed, ":", { plain = true }) 144 | if #parts >= 2 then 145 | local filename = vim.fn.trim(parts[1]) 146 | local lnum = tonumber(vim.fn.trim(parts[2])) 147 | 148 | if vim.fn.filereadable(filename) == 1 and lnum then 149 | local qf_entry = { 150 | filename = filename, 151 | lnum = lnum, 152 | } 153 | 154 | local text_start_idx = 3 155 | if #parts >= 3 then 156 | local potential_col = tonumber(vim.fn.trim(parts[3])) 157 | if potential_col then 158 | qf_entry.col = potential_col 159 | text_start_idx = 4 160 | end 161 | end 162 | 163 | if #parts >= text_start_idx then 164 | local text = table.concat(vim.list_slice(parts, text_start_idx), ":") 165 | text = vim.fn.trim(text) 166 | if text ~= "" then 167 | qf_entry.text = text 168 | end 169 | end 170 | 171 | if not qf_entry.text then 172 | local file_lines = vim.fn.readfile(filename, "", lnum) 173 | if #file_lines > 0 then 174 | qf_entry.text = vim.fn.trim(file_lines[#file_lines]) 175 | end 176 | end 177 | 178 | table.insert(qf_items, qf_entry) 179 | goto continue 180 | end 181 | end 182 | 183 | if vim.fn.filereadable(trimmed) == 1 then 184 | local qf_entry = { 185 | filename = trimmed, 186 | lnum = 1, 187 | } 188 | local file_lines = vim.fn.readfile(trimmed, "", 1) 189 | if #file_lines > 0 then 190 | qf_entry.text = vim.fn.trim(file_lines[1]) 191 | end 192 | table.insert(qf_items, qf_entry) 193 | goto continue 194 | end 195 | 196 | table.insert(qf_items, { text = trimmed }) 197 | 198 | ::continue:: 199 | end 200 | utils.populate_quickfix(qf_items, "TV", config) 201 | end 202 | 203 | ---Execute shell command (use {} as placeholder) 204 | ---@param command_template string Command template (e.g., "git checkout {}") 205 | ---@return tv.Handler 206 | function M.execute_shell_command(command_template) 207 | return function(entries, _) 208 | if #entries > 0 then 209 | local entry = vim.fn.shellescape(entries[1]) 210 | local command = command_template:gsub("{}", entry) 211 | vim.cmd("!" .. command) 212 | end 213 | end 214 | end 215 | 216 | ---Show entries in vim.ui.select for further action 217 | ---@param entries string[] 218 | ---@param _ tv.Config 219 | function M.show_in_select(entries, _) 220 | if #entries == 0 then 221 | return 222 | end 223 | vim.ui.select(entries, { 224 | prompt = "Select entry:", 225 | }, function(choice) 226 | if choice then 227 | vim.fn.setreg("+", choice) 228 | vim.notify("Copied: " .. choice, vim.log.levels.INFO, { title = "tv.nvim" }) 229 | end 230 | end) 231 | end 232 | 233 | return M 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # tv.nvim 4 | 5 | [![Neovim](https://img.shields.io/badge/Neovim-0.9%2B-7e98e8.svg?style=for-the-badge&logo=neovim)](https://neovim.io/) 6 | ![Lua](https://img.shields.io/badge/Made%20with%20Lua-8faf77.svg?style=for-the-badge&logo=lua) 7 | 8 | 9 | **📺 Neovim integration for [television](https://github.com/alexpasmantier/television).** 10 |
11 | 12 | ![demo](https://github.com/user-attachments/assets/14e743a5-e839-4eed-963b-e111d4e7d8b2) 13 | 14 | The initial idea behind television was to create something like the popular telescope.nvim plugin, but as a standalone terminal application - keeping telescope's modularity without the Neovim dependency, and benefiting from Rust's performance. 15 | 16 | This plugin brings Television back into Neovim through a thin Lua wrapper around the binary. It started as a way to dogfood my own project, but might be of interest to other tv enthusiasts as well. Full circle. 17 | 18 | ## Overview 19 | 20 | If you're already familiar with [television](https://github.com/alexpasmantier/television), this plugin basically lets you launch any of its channels from within Neovim, and decide what to do with the selected results (open as buffers, send to 21 | quickfix, copy to clipboard, insert at cursor, checkout with git, etc.) using lua. 22 | 23 | ### Examples 24 | 25 | | text | git-log | 26 | | :--------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------: | 27 | | text | git-log | 28 | | **tldr** | **gh-prs** | 29 | | tldr | gh-prs | 30 | 31 |
32 | 33 | Curious about channels available in television? Check out [this 34 | page](https://alexpasmantier.github.io/television/docs/Users/community-channels-unix). 35 | 36 |
37 | 38 | ## Installation 39 | 40 | ```lua 41 | -- lazy.nvim 42 | { 43 | "alexpasmantier/tv.nvim", 44 | config = function() 45 | require("tv").setup{ 46 | -- your config here (see Configuration section below) 47 | } 48 | end, 49 | } 50 | 51 | -- packer.nvim 52 | use { 53 | "alexpasmantier/tv.nvim", 54 | config = function() 55 | require("tv").setup{ 56 | -- your config here (see Configuration section below) 57 | } 58 | end, 59 | } 60 | ``` 61 | 62 | **Note**: requires [television](https://github.com/alexpasmantier/television) to be installed and available in your PATH. 63 | 64 | ## Configuration 65 | 66 | ### Basic Setup 67 | 68 | Here's a minimal setup example to get you started, which includes configuration for the `files` and `text` channels that 69 | are the most commonly used ones: 70 | 71 | ```lua 72 | local h = require('tv').handlers 73 | 74 | require('tv').setup({ 75 | -- per-channel configurations 76 | channels = { 77 | -- `files`: fuzzy find files in your project 78 | files = { 79 | keybinding = '', -- Launch the files channel 80 | -- what happens when you press a key 81 | handlers = { 82 | [''] = h.open_as_files, -- default: open selected files 83 | [''] = h.send_to_quickfix, -- send to quickfix list 84 | [''] = h.open_in_split, -- open in horizontal split 85 | [''] = h.open_in_vsplit, -- open in vertical split 86 | [''] = h.copy_to_clipboard, -- copy paths to clipboard 87 | }, 88 | }, 89 | -- `text`: ripgrep search through file contents 90 | text = { 91 | keybinding = '', 92 | handlers = { 93 | [''] = h.open_at_line, -- Jump to line:col in file 94 | [''] = h.send_to_quickfix, -- Send matches to quickfix 95 | [''] = h.open_in_split, -- Open in horizontal split 96 | [''] = h.open_in_vsplit, -- Open in vertical split 97 | [''] = h.copy_to_clipboard, -- Copy matches to clipboard 98 | }, 99 | }, 100 | }, 101 | }) 102 | 103 | ``` 104 | 105 | ### Full Configuration 106 | 107 | Here's a comprehensive configuration example demonstrating the plugin's capabilities: 108 | 109 | ```lua 110 | -- built-in niceties 111 | local h = require('tv').handlers 112 | 113 | require('tv').setup({ 114 | -- global window appearance (can be overridden per channel) 115 | window = { 116 | width = 0.8, -- 80% of editor width 117 | height = 0.8, -- 80% of editor height 118 | border = 'none', 119 | title = ' tv.nvim ', 120 | title_pos = 'center', 121 | }, 122 | -- per-channel configurations 123 | channels = { 124 | -- `files`: fuzzy find files in your project 125 | files = { 126 | keybinding = '', -- Launch the files channel 127 | -- what happens when you press a key 128 | handlers = { 129 | [''] = h.open_as_files, -- default: open selected files 130 | [''] = h.send_to_quickfix, -- send to quickfix list 131 | [''] = h.open_in_split, -- open in horizontal split 132 | [''] = h.open_in_vsplit, -- open in vertical split 133 | [''] = h.copy_to_clipboard, -- copy paths to clipboard 134 | }, 135 | }, 136 | 137 | -- `text`: ripgrep search through file contents 138 | text = { 139 | keybinding = '', 140 | handlers = { 141 | [''] = h.open_at_line, -- Jump to line:col in file 142 | [''] = h.send_to_quickfix, -- Send matches to quickfix 143 | [''] = h.open_in_split, -- Open in horizontal split 144 | [''] = h.open_in_vsplit, -- Open in vertical split 145 | [''] = h.copy_to_clipboard, -- Copy matches to clipboard 146 | }, 147 | }, 148 | 149 | -- `git-log`: browse commit history 150 | ['git-log'] = { 151 | keybinding = 'gl', 152 | handlers = { 153 | -- custom handler: show commit diff in scratch buffer 154 | [''] = function(entries, config) 155 | if #entries > 0 then 156 | vim.cmd('enew | setlocal buftype=nofile bufhidden=wipe') 157 | vim.cmd('silent 0read !git show ' .. vim.fn.shellescape(entries[1])) 158 | vim.cmd('1delete _ | setlocal filetype=git nomodifiable') 159 | vim.cmd('normal! gg') 160 | end 161 | end, 162 | -- copy commit hash to clipboard 163 | [''] = h.copy_to_clipboard, 164 | }, 165 | }, 166 | 167 | -- `git-branch`: browse git branches 168 | ['git-branch'] = { 169 | keybinding = 'gb', 170 | handlers = { 171 | -- checkout branch using execute_shell_command helper 172 | -- {} is replaced with the selected entry 173 | [''] = h.execute_shell_command('git checkout {}'), 174 | [''] = h.copy_to_clipboard, 175 | }, 176 | }, 177 | 178 | -- `docker-images`: browse images and run containers 179 | ['docker-images'] = { 180 | keybinding = 'di', 181 | window = { title = ' Docker Images ' }, 182 | handlers = { 183 | -- run a container with the selected image 184 | [''] = function(entries, config) 185 | if #entries > 0 then 186 | vim.ui.input({ 187 | prompt = 'Container name: ', 188 | default = 'my-container', 189 | }, function(name) 190 | if name and name ~= '' then 191 | local cmd = string.format('docker run -it --name %s %s', name, entries[1]) 192 | vim.cmd('!' .. cmd) 193 | end 194 | end) 195 | end 196 | end, 197 | -- copy image name 198 | [''] = h.copy_to_clipboard, 199 | }, 200 | }, 201 | 202 | -- `env`: search environment variables 203 | env = { 204 | keybinding = 'ev', 205 | handlers = { 206 | [''] = h.insert_at_cursor, -- Insert at cursor position 207 | [''] = h.insert_on_new_line, -- Insert on new line 208 | [''] = h.copy_to_clipboard, 209 | }, 210 | }, 211 | 212 | -- `aliases`: search shell aliases 213 | alias = { 214 | keybinding = 'al', 215 | handlers = { 216 | [''] = h.insert_at_cursor, 217 | [''] = h.copy_to_clipboard, 218 | }, 219 | }, 220 | }, 221 | -- path to the tv binary (default: 'tv') 222 | tv_binary = 'tv', 223 | global_keybindings = { 224 | channels = 'tv', -- opens the channel selector 225 | }, 226 | quickfix = { 227 | auto_open = true, -- automatically open quickfix window after populating 228 | }, 229 | 230 | }) 231 | ``` 232 | 233 | ### Usage 234 | 235 | **Commands:** 236 | 237 | ```vim 238 | :Tv files " Find files 239 | :Tv text " Search text in files 240 | :Tv text @TODO " Search with pre-populated query 241 | :Tv git-log " Browse commits 242 | :Tv " Open channel selector 243 | ``` 244 | 245 | **Or use the keybindings you configured above.** 246 | 247 | TV comes with 30+ built-in channels. Use `:Tv` to see all available channels, or try: 248 | 249 | ```vim 250 | :Tv git-branch " Switch branches 251 | :Tv zsh-history " Browse command history 252 | :Tv procs " List running processes 253 | ``` 254 | 255 | Tab completion works: `:Tv ` 256 | 257 | ### Built-in Handlers Reference 258 | 259 | ```lua 260 | local h = require('tv').handlers 261 | 262 | -- File operations 263 | h.open_as_files -- Open selected entries as file buffers 264 | h.open_at_line -- Open file at specific line:col (for text search results) 265 | h.open_in_split -- Open in horizontal split 266 | h.open_in_vsplit -- Open in vertical split 267 | h.open_in_scratch -- Open in scratch (nofile) buffer 268 | 269 | -- List operations 270 | h.send_to_quickfix -- Populate quickfix list with results 271 | 272 | -- Text operations 273 | h.copy_to_clipboard -- Copy entries to system clipboard 274 | h.insert_at_cursor -- Insert at cursor position 275 | h.insert_on_new_line -- Insert each entry on new line 276 | 277 | -- Interactive 278 | h.show_in_select -- Show vim.ui.select() menu for further actions 279 | 280 | -- Shell execution 281 | h.execute_shell_command(cmd) -- Execute shell command with selected entry 282 | -- Use {} as placeholder for the entry 283 | ``` 284 | 285 | Note: handlers are expected to be of the following signature: 286 | 287 | ```lua 288 | ---@alias tv.Handler fun(entries: string[], config: tv.Config) 289 | ``` 290 | 291 | ## License 292 | 293 | MIT 294 | -------------------------------------------------------------------------------- /doc/tv.nvim-docs.txt: -------------------------------------------------------------------------------- 1 | *tv.nvim-docs.txt* For Neovim >= 0.8.0 Last change: 2025 December 06 2 | 3 | ============================================================================== 4 | Table of Contents *tv.nvim-docs-table-of-contents* 5 | 6 | - Overview |tv.nvim-docs-overview| 7 | - Installation |tv.nvim-docs-installation| 8 | - Configuration |tv.nvim-docs-configuration| 9 | - License |tv.nvim-docs-license| 10 | 1. Links |tv.nvim-docs-links| 11 | 12 | The initial idea behind television was to create something like the popular 13 | telescope.nvim plugin, but as a standalone terminal application - keeping 14 | telescope’s modularity without the Neovim dependency, and benefiting from 15 | Rust’s performance. 16 | 17 | This plugin brings Television back into Neovim through a thin Lua wrapper 18 | around the binary. It started as a way to dogfood my own project, but might be 19 | of interest to other tv enthusiasts as well. Full circle. 20 | 21 | 22 | OVERVIEW *tv.nvim-docs-overview* 23 | 24 | If you’re already familiar with television 25 | , this plugin basically lets you 26 | launch any of its channels from within Neovim, and decide what to do with the 27 | selected results (open as buffers, send to quickfix, copy to clipboard, insert 28 | at cursor, checkout with git, etc.) using lua. 29 | 30 | 31 | EXAMPLES ~ 32 | 33 | ----------------------------------------------------------------------- 34 | text git-log 35 | ----------------------------------- ----------------------------------- 36 | 37 | 38 | tldr gh-prs 39 | 40 | 41 | ----------------------------------------------------------------------- 42 | 43 | 44 | INSTALLATION *tv.nvim-docs-installation* 45 | 46 | >lua 47 | -- lazy.nvim 48 | { 49 | "alexpasmantier/tv.nvim", 50 | config = function() 51 | require("tv").setup{ 52 | -- your config here (see Configuration section below) 53 | } 54 | end, 55 | } 56 | 57 | -- packer.nvim 58 | use { 59 | "alexpasmantier/tv.nvim", 60 | config = function() 61 | require("tv").setup{ 62 | -- your config here (see Configuration section below) 63 | } 64 | end, 65 | } 66 | < 67 | 68 | **Note**: requires television to 69 | be installed and available in your PATH. 70 | 71 | 72 | CONFIGURATION *tv.nvim-docs-configuration* 73 | 74 | 75 | BASIC SETUP ~ 76 | 77 | Here’s a minimal setup example to get you started, which includes 78 | configuration for the `files` and `text` channels that are the most commonly 79 | used ones: 80 | 81 | >lua 82 | local h = require('tv').handlers 83 | 84 | require('tv').setup({ 85 | -- per-channel configurations 86 | channels = { 87 | -- `files`: fuzzy find files in your project 88 | files = { 89 | keybinding = '', -- Launch the files channel 90 | -- what happens when you press a key 91 | handlers = { 92 | [''] = h.open_as_files, -- default: open selected files 93 | [''] = h.send_to_quickfix, -- send to quickfix list 94 | [''] = h.open_in_split, -- open in horizontal split 95 | [''] = h.open_in_vsplit, -- open in vertical split 96 | [''] = h.copy_to_clipboard, -- copy paths to clipboard 97 | }, 98 | }, 99 | -- `text`: ripgrep search through file contents 100 | text = { 101 | keybinding = '', 102 | handlers = { 103 | [''] = h.open_at_line, -- Jump to line:col in file 104 | [''] = h.send_to_quickfix, -- Send matches to quickfix 105 | [''] = h.open_in_split, -- Open in horizontal split 106 | [''] = h.open_in_vsplit, -- Open in vertical split 107 | [''] = h.copy_to_clipboard, -- Copy matches to clipboard 108 | }, 109 | }, 110 | }, 111 | }) 112 | < 113 | 114 | 115 | FULL CONFIGURATION ~ 116 | 117 | Here’s a comprehensive configuration example demonstrating the plugin’s 118 | capabilities: 119 | 120 | >lua 121 | -- built-in niceties 122 | local h = require('tv').handlers 123 | 124 | require('tv').setup({ 125 | -- global window appearance (can be overridden per channel) 126 | window = { 127 | width = 0.8, -- 80% of editor width 128 | height = 0.8, -- 80% of editor height 129 | border = 'none', 130 | title = ' tv.nvim ', 131 | title_pos = 'center', 132 | }, 133 | -- per-channel configurations 134 | channels = { 135 | -- `files`: fuzzy find files in your project 136 | files = { 137 | keybinding = '', -- Launch the files channel 138 | -- what happens when you press a key 139 | handlers = { 140 | [''] = h.open_as_files, -- default: open selected files 141 | [''] = h.send_to_quickfix, -- send to quickfix list 142 | [''] = h.open_in_split, -- open in horizontal split 143 | [''] = h.open_in_vsplit, -- open in vertical split 144 | [''] = h.copy_to_clipboard, -- copy paths to clipboard 145 | }, 146 | }, 147 | 148 | -- `text`: ripgrep search through file contents 149 | text = { 150 | keybinding = '', 151 | handlers = { 152 | [''] = h.open_at_line, -- Jump to line:col in file 153 | [''] = h.send_to_quickfix, -- Send matches to quickfix 154 | [''] = h.open_in_split, -- Open in horizontal split 155 | [''] = h.open_in_vsplit, -- Open in vertical split 156 | [''] = h.copy_to_clipboard, -- Copy matches to clipboard 157 | }, 158 | }, 159 | 160 | -- `git-log`: browse commit history 161 | ['git-log'] = { 162 | keybinding = 'gl', 163 | handlers = { 164 | -- custom handler: show commit diff in scratch buffer 165 | [''] = function(entries, config) 166 | if #entries > 0 then 167 | vim.cmd('enew | setlocal buftype=nofile bufhidden=wipe') 168 | vim.cmd('silent 0read !git show ' .. vim.fn.shellescape(entries[1])) 169 | vim.cmd('1delete _ | setlocal filetype=git nomodifiable') 170 | vim.cmd('normal! gg') 171 | end 172 | end, 173 | -- copy commit hash to clipboard 174 | [''] = h.copy_to_clipboard, 175 | }, 176 | }, 177 | 178 | -- `git-branch`: browse git branches 179 | ['git-branch'] = { 180 | keybinding = 'gb', 181 | handlers = { 182 | -- checkout branch using execute_shell_command helper 183 | -- {} is replaced with the selected entry 184 | [''] = h.execute_shell_command('git checkout {}'), 185 | [''] = h.copy_to_clipboard, 186 | }, 187 | }, 188 | 189 | -- `docker-images`: browse images and run containers 190 | ['docker-images'] = { 191 | keybinding = 'di', 192 | window = { title = ' Docker Images ' }, 193 | handlers = { 194 | -- run a container with the selected image 195 | [''] = function(entries, config) 196 | if #entries > 0 then 197 | vim.ui.input({ 198 | prompt = 'Container name: ', 199 | default = 'my-container', 200 | }, function(name) 201 | if name and name ~= '' then 202 | local cmd = string.format('docker run -it --name %s %s', name, entries[1]) 203 | vim.cmd('!' .. cmd) 204 | end 205 | end) 206 | end 207 | end, 208 | -- copy image name 209 | [''] = h.copy_to_clipboard, 210 | }, 211 | }, 212 | 213 | -- `env`: search environment variables 214 | env = { 215 | keybinding = 'ev', 216 | handlers = { 217 | [''] = h.insert_at_cursor, -- Insert at cursor position 218 | [''] = h.insert_on_new_line, -- Insert on new line 219 | [''] = h.copy_to_clipboard, 220 | }, 221 | }, 222 | 223 | -- `aliases`: search shell aliases 224 | alias = { 225 | keybinding = 'al', 226 | handlers = { 227 | [''] = h.insert_at_cursor, 228 | [''] = h.copy_to_clipboard, 229 | }, 230 | }, 231 | }, 232 | -- path to the tv binary (default: 'tv') 233 | tv_binary = 'tv', 234 | global_keybindings = { 235 | channels = 'tv', -- opens the channel selector 236 | }, 237 | quickfix = { 238 | auto_open = true, -- automatically open quickfix window after populating 239 | }, 240 | 241 | }) 242 | < 243 | 244 | 245 | USAGE ~ 246 | 247 | **Commands:** 248 | 249 | >vim 250 | :Tv files " Find files 251 | :Tv text " Search text in files 252 | :Tv text @TODO " Search with pre-populated query 253 | :Tv git-log " Browse commits 254 | :Tv " Open channel selector 255 | < 256 | 257 | **Or use the keybindings you configured above.** 258 | 259 | TV comes with 30+ built-in channels. Use `:Tv` to see all available channels, 260 | or try: 261 | 262 | >vim 263 | :Tv git-branch " Switch branches 264 | :Tv zsh-history " Browse command history 265 | :Tv procs " List running processes 266 | < 267 | 268 | Tab completion works: `:Tv ` 269 | 270 | 271 | BUILT-IN HANDLERS REFERENCE ~ 272 | 273 | >lua 274 | local h = require('tv').handlers 275 | 276 | -- File operations 277 | h.open_as_files -- Open selected entries as file buffers 278 | h.open_at_line -- Open file at specific line:col (for text search results) 279 | h.open_in_split -- Open in horizontal split 280 | h.open_in_vsplit -- Open in vertical split 281 | h.open_in_scratch -- Open in scratch (nofile) buffer 282 | 283 | -- List operations 284 | h.send_to_quickfix -- Populate quickfix list with results 285 | 286 | -- Text operations 287 | h.copy_to_clipboard -- Copy entries to system clipboard 288 | h.insert_at_cursor -- Insert at cursor position 289 | h.insert_on_new_line -- Insert each entry on new line 290 | 291 | -- Interactive 292 | h.show_in_select -- Show vim.ui.select() menu for further actions 293 | 294 | -- Shell execution 295 | h.execute_shell_command(cmd) -- Execute shell command with selected entry 296 | -- Use {} as placeholder for the entry 297 | < 298 | 299 | Note: handlers are expected to be of the following signature: 300 | 301 | >lua 302 | ---@alias tv.Handler fun(entries: string[], config: tv.Config) 303 | < 304 | 305 | 306 | LICENSE *tv.nvim-docs-license* 307 | 308 | MIT 309 | 310 | ============================================================================== 311 | 1. Links *tv.nvim-docs-links* 312 | 313 | 1. *demo*: https://github.com/user-attachments/assets/14e743a5-e839-4eed-963b-e111d4e7d8b2 314 | 315 | Generated by panvimdoc 316 | 317 | vim:tw=78:ts=8:noet:ft=help:norl: 318 | --------------------------------------------------------------------------------