14 changed files with 551 additions and 182 deletions
@ -1,145 +0,0 @@
|
||||
-- Terminal helpers layered on top of the native LazyVim/Snacks terminal. |
||||
-- |
||||
-- Native behaviour is kept intact and untouched: |
||||
-- <C-/> / <C-_> toggle a bottom terminal at the project root (supports a |
||||
-- count, e.g. 2<C-/> opens a *separate* terminal #2) |
||||
-- <leader>fT terminal in the current working directory |
||||
-- |
||||
-- This module adds (actual key bindings are declared in lua/plugins/terminal.lua): |
||||
-- open_float() open/focus a *dedicated* floating terminal (close it with <C-/>) |
||||
-- picker() list every live terminal so any contextual terminal can be |
||||
-- focused or restored even after its window was closed/hidden. |
||||
local M = {} |
||||
|
||||
-- The floating terminal. Tracked as an upvalue so the same key always re-focuses |
||||
-- the *same* float regardless of which directory the editor is in. |
||||
---@type snacks.win? |
||||
local float |
||||
|
||||
--- Open or focus the floating terminal, or hide it if already focused. |
||||
-- NOTE: currently unused; the binding calls open_float() instead so shell |
||||
-- tab-completion keeps working while the terminal is open. Kept around for a |
||||
-- future explicit open/close toggle. |
||||
function M.toggle_float() |
||||
-- Reuse the existing instance: hide if visible, re-show if hidden. |
||||
if float and float:buf_valid() then |
||||
if float:win_valid() then |
||||
float:hide() |
||||
else |
||||
float:show() |
||||
vim.cmd("startinsert") |
||||
end |
||||
return |
||||
end |
||||
|
||||
-- First open: a high fixed count guarantees this terminal never collides with |
||||
-- the count-keyed bottom terminals (1, 2, 3 ...), so it is always the *same* |
||||
-- float for <C-Tab> no matter the context. |
||||
float = Snacks.terminal(nil, { |
||||
count = 99, |
||||
win = { position = "float", border = "rounded" }, |
||||
}) |
||||
|
||||
-- Close it with a single keystroke from inside the shell: hides the |
||||
-- window (buffer-local, so it only affects this floating terminal and never |
||||
-- the bottom split, where shell tab-completion must keep working). |
||||
if float and float.buf then |
||||
vim.keymap.set("t", "<leader>tt", function() |
||||
if float and float:win_valid() then |
||||
float:hide() |
||||
end |
||||
end, { buffer = float.buf, silent = true, desc = "Hide floating terminal" }) |
||||
end |
||||
end |
||||
|
||||
--- Open or focus the floating terminal. This never hides the window, so |
||||
--- once the float is open the key falls through to the shell and stays available |
||||
--- for tab-completion. Close the floating terminal with <C-/> while it is |
||||
--- visible (handled by Snacks' native terminal keymap). |
||||
function M.open_float() |
||||
if float and float:buf_valid() then |
||||
if not float:win_valid() then |
||||
float:show() |
||||
end |
||||
if float:win_valid() then |
||||
vim.api.nvim_set_current_win(float.win) |
||||
end |
||||
vim.cmd("startinsert") |
||||
return |
||||
end |
||||
|
||||
-- First open: a high fixed count guarantees this terminal never collides with |
||||
-- the count-keyed bottom terminals (1, 2, 3 ...), so it is always the *same* |
||||
-- float for <C-Tab> no matter the context. |
||||
float = Snacks.terminal(nil, { |
||||
count = 99, |
||||
win = { position = "float", border = "rounded" }, |
||||
}) |
||||
end |
||||
|
||||
--- List every live Snacks terminal in a picker. Restore/focus one with <CR>, |
||||
--- kill one with <d>. |
||||
function M.picker() |
||||
local terms = Snacks.terminal.list() |
||||
if #terms == 0 then |
||||
Snacks.notify.info("No open terminals") |
||||
return |
||||
end |
||||
|
||||
local items = {} ---@type snacks.picker.finder.Item[] |
||||
for _, term in ipairs(terms) do |
||||
local meta = vim.b[term.buf] and vim.b[term.buf].snacks_terminal or {} |
||||
items[#items + 1] = { |
||||
term = term, |
||||
buf = term.buf, |
||||
id = meta.id, |
||||
cwd = meta.cwd or "?", |
||||
cmd = meta.cmd or vim.o.shell, |
||||
visible = term:win_valid(), |
||||
text = ("#%s %s %s"):format(meta.id or "?", meta.cwd or "?", meta.cmd or vim.o.shell), |
||||
} |
||||
end |
||||
|
||||
Snacks.picker({ |
||||
title = "Terminals", |
||||
items = items, |
||||
format = function(item) |
||||
local ret = {} ---@type snacks.picker.Highlight[] |
||||
ret[#ret + 1] = { |
||||
item.visible and "● " or "○ ", |
||||
item.visible and "SnacksPickerSpecial" or "SnacksPickerComment", |
||||
} |
||||
ret[#ret + 1] = { ("#%-2s"):format(item.id or "?"), "Number" } |
||||
ret[#ret + 1] = { " " .. vim.fn.fnamemodify(item.cwd, ":~"), "SnacksPickerPath" } |
||||
ret[#ret + 1] = { " " .. vim.fn.fnamemodify(item.cmd, ":t"), "SnacksPickerComment" } |
||||
return ret |
||||
end, |
||||
confirm = function(picker, item) |
||||
picker:close() |
||||
local term = item.term |
||||
if not term:buf_valid() then |
||||
Snacks.notify.warn("Terminal no longer exists") |
||||
return |
||||
end |
||||
if term:win_valid() then |
||||
vim.api.nvim_set_current_win(term.win) |
||||
else |
||||
term:show() |
||||
vim.cmd("startinsert") |
||||
end |
||||
end, |
||||
win = { input = { keys = { ["d"] = "term_delete" } } }, |
||||
actions = { |
||||
term_delete = function(picker, item) |
||||
local term = item.term |
||||
picker:close() |
||||
if term and term:buf_valid() then |
||||
term:close() |
||||
pcall(vim.api.nvim_buf_delete, term.buf, { force = true }) |
||||
end |
||||
end, |
||||
}, |
||||
}) |
||||
end |
||||
|
||||
return M |
||||
@ -0,0 +1,11 @@
|
||||
return { |
||||
{ |
||||
"chodak166/clangd-container.nvim", |
||||
-- dir = vim.fn.expand("~/src/git/clangd-container.nvim"), |
||||
dependencies = { "p00f/clangd_extensions.nvim" }, |
||||
opts = {}, |
||||
keys = { |
||||
{ "<leader>cC", "<cmd>ClangdContainer<cr>", desc = "Clangd Container" }, |
||||
}, |
||||
}, |
||||
} |
||||
@ -0,0 +1,13 @@
|
||||
return { |
||||
{ |
||||
"https://codeberg.org/esensar/nvim-dev-container", |
||||
dependencies = "nvim-treesitter/nvim-treesitter", |
||||
config = function() |
||||
require("devcontainer").setup({ |
||||
-- Your options go here |
||||
-- e.g., auto_start = true, icons = { ... } |
||||
container_runtime = docker, |
||||
}) |
||||
end, |
||||
}, |
||||
} |
||||
@ -1,23 +1,11 @@
|
||||
-- Terminal UX layered on Snacks (replaces toggleterm). |
||||
-- Helpers live in lua/config/terminal.lua; keys are declared here so lazy.nvim |
||||
-- registers the which-key labels with the rest of the snacks spec. |
||||
-- All keymaps (<leader>tt, <leader>ft, <C-/> with 99-count float) are |
||||
-- registered by the plugin's setup(). |
||||
return { |
||||
"folke/snacks.nvim", |
||||
keys = { |
||||
{ |
||||
"<leader>tt", |
||||
function() |
||||
require("config.terminal").open_float() |
||||
end, |
||||
desc = "Terminal (float)", |
||||
mode = "n", |
||||
}, |
||||
{ |
||||
"<leader>ft", |
||||
function() |
||||
require("config.terminal").picker() |
||||
end, |
||||
desc = "Terminals (list/restore)", |
||||
}, |
||||
}, |
||||
"chodak166/snacks-terminal-ctl.nvim", |
||||
-- dir = "~/src/git/snacks-terminal-ctl.nvim", |
||||
name = "snacks-terminal-ctl.nvim", |
||||
dependencies = { "folke/snacks.nvim" }, |
||||
event = "VeryLazy", |
||||
opts = {}, |
||||
} |
||||
|
||||
@ -0,0 +1,135 @@
|
||||
local M = {} |
||||
|
||||
local function get_yanked_info() |
||||
return ya.sync(function() |
||||
local cwd = cx.active.current.cwd |
||||
local cut = cx.yanked.is_cut |
||||
|
||||
local yanked = {} |
||||
for _, f in pairs(cx.yanked) do |
||||
yanked[#yanked + 1] = { url = f.url, name = tostring(f.url:name()) } |
||||
end |
||||
|
||||
return cwd, cut, yanked |
||||
end) |
||||
end |
||||
|
||||
local function rename_default(name) |
||||
local suffix = os.date("-%y%m%d") |
||||
local stem = name:match("^(.+)%..-$") or name |
||||
local ext = name:match("%.(.+)$") or "" |
||||
if ext ~= "" then |
||||
return stem .. suffix .. "." .. ext |
||||
else |
||||
return name .. suffix |
||||
end |
||||
end |
||||
|
||||
function M:entry(_, job) |
||||
local cwd, cut, yanked = get_yanked_info() |
||||
if #yanked == 0 then |
||||
return |
||||
end |
||||
|
||||
local follow = job.args.follow or false |
||||
local resolved = {} |
||||
|
||||
-- Partition: check each yanked item for conflicts |
||||
local conflicts = {} |
||||
for _, item in ipairs(yanked) do |
||||
local dest = cwd:join(item.name) |
||||
local cha = fs.cha(dest) |
||||
if cha ~= nil then |
||||
conflicts[#conflicts + 1] = { url = item.url, dest = dest, name = item.name } |
||||
else |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(dest), |
||||
overwrite = false, |
||||
} |
||||
end |
||||
end |
||||
|
||||
-- Process conflicts |
||||
local sync_all = false |
||||
for _, item in ipairs(conflicts) do |
||||
if sync_all then |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
} |
||||
else |
||||
local choice = ya.pick({ |
||||
title = "Paste conflict: " .. item.name, |
||||
items = { |
||||
"Sync (merge/overwrite)", |
||||
"Sync all remaining", |
||||
"Rename", |
||||
"Skip", |
||||
"Cancel", |
||||
}, |
||||
}) |
||||
|
||||
-- ya.pick returns 0-based index or nil |
||||
if choice == nil or choice == 4 then |
||||
-- Cancel: paste what we have so far, then abort |
||||
if #resolved > 0 then |
||||
ya.emit("paste_resolved", { |
||||
cut = cut, |
||||
follow = follow, |
||||
items = resolved, |
||||
}) |
||||
end |
||||
return |
||||
|
||||
elseif choice == 0 then |
||||
-- Sync current |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
} |
||||
|
||||
elseif choice == 1 then |
||||
-- Sync all remaining |
||||
sync_all = true |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
} |
||||
|
||||
elseif choice == 2 then |
||||
-- Rename |
||||
local default = rename_default(item.name) |
||||
local value, event = ya.input({ |
||||
title = "New name", |
||||
value = default, |
||||
}) |
||||
if event == 1 then |
||||
local new_dest = cwd:join(value) |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(new_dest), |
||||
overwrite = false, |
||||
} |
||||
end |
||||
|
||||
elseif choice == 3 then |
||||
-- Skip: do nothing |
||||
end |
||||
end |
||||
end |
||||
|
||||
-- Dispatch all resolved items |
||||
if #resolved > 0 then |
||||
ya.emit("paste_resolved", { |
||||
cut = cut, |
||||
follow = follow, |
||||
items = resolved, |
||||
}) |
||||
end |
||||
end |
||||
|
||||
return M |
||||
@ -0,0 +1,21 @@
|
||||
MIT License |
||||
|
||||
Copyright (c) 2026 chodak166 |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
||||
@ -0,0 +1,220 @@
|
||||
local M = {} |
||||
|
||||
local get_yanked_info = ya.sync(function() |
||||
local cwd = cx.active.current.cwd |
||||
local cut = cx.yanked.is_cut |
||||
|
||||
local yanked = {} |
||||
for _, url in ipairs(cx.yanked:urls()) do |
||||
yanked[#yanked + 1] = { url = url, name = tostring(url.name) } |
||||
end |
||||
|
||||
return { cwd = cwd, cut = cut, yanked = yanked } |
||||
end) |
||||
|
||||
local function rename_default(name, attempt) |
||||
if attempt and attempt > 1 then |
||||
local suffix = string.format("-%d", attempt) |
||||
local stem = name:match("^(.+)%..-$") or name |
||||
local ext = name:match("%.(.+)$") or "" |
||||
if ext ~= "" then |
||||
return stem .. suffix .. "." .. ext |
||||
else |
||||
return name .. suffix |
||||
end |
||||
end |
||||
local suffix = os.date("-%y%m%d") |
||||
local stem = name:match("^(.+)%..-$") or name |
||||
local ext = name:match("%.(.+)$") or "" |
||||
if ext ~= "" then |
||||
return stem .. suffix .. "." .. ext |
||||
else |
||||
return name .. suffix |
||||
end |
||||
end |
||||
|
||||
--Choices: |
||||
-- 0: Merge — merge directory contents, overwrite individual files |
||||
-- 1: Merge all — same, applied to all remaining conflicts |
||||
-- 2: Replace — replace destination entirely (delete then copy) |
||||
-- 3: Rename — prompt for a new name |
||||
-- 4: Skip — skip this item |
||||
-- 5: Cancel(nil) — abort |
||||
local function pick_conflict(name) |
||||
return ya.pick({ |
||||
title = "Paste conflict: " .. name, |
||||
items = { |
||||
"Merge", |
||||
"Merge all remaining", |
||||
"Replace", |
||||
"Rename", |
||||
"Skip", |
||||
"Cancel", |
||||
}, |
||||
}) |
||||
end |
||||
|
||||
function M:entry(job) |
||||
local info = get_yanked_info() |
||||
local cwd, cut, yanked = info.cwd, info.cut, info.yanked |
||||
|
||||
if #yanked == 0 then |
||||
return |
||||
end |
||||
|
||||
local follow = job.args.follow or false |
||||
local resolved = {} |
||||
|
||||
-- Partition: check each yanked item for conflicts |
||||
local conflicts = {} |
||||
for _, item in ipairs(yanked) do |
||||
local dest = cwd:join(item.name) |
||||
local cha = fs.cha(dest) |
||||
if cha ~= nil then |
||||
conflicts[#conflicts + 1] = { url = item.url, dest = dest, name = item.name } |
||||
else |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(dest), |
||||
overwrite = false, |
||||
replace = false, |
||||
} |
||||
end |
||||
end |
||||
|
||||
-- Dispatch helper |
||||
local function dispatch(items) |
||||
if #items > 0 then |
||||
ya.emit("paste_resolved", { |
||||
cut = cut, |
||||
follow = follow, |
||||
items = items, |
||||
}) |
||||
end |
||||
end |
||||
|
||||
-- Process conflicts |
||||
local merge_all = false |
||||
for _, item in ipairs(conflicts) do |
||||
if merge_all then |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
replace = false, |
||||
} |
||||
else |
||||
local choice = pick_conflict(item.name) |
||||
|
||||
if choice == nil or choice == 5 then |
||||
-- Cancel: paste what we have so far, then abort |
||||
dispatch(resolved) |
||||
return |
||||
|
||||
elseif choice == 0 then |
||||
-- Merge current |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
replace = false, |
||||
} |
||||
|
||||
elseif choice == 1 then |
||||
-- Merge all remaining |
||||
merge_all = true |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
replace = false, |
||||
} |
||||
|
||||
elseif choice == 2 then |
||||
-- Replace |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(item.dest), |
||||
overwrite = true, |
||||
replace = true, |
||||
} |
||||
|
||||
elseif choice == 3 then |
||||
-- Rename: loop until a non-conflicting name is chosen or cancelled |
||||
local attempt = 0 |
||||
while true do |
||||
attempt = attempt + 1 |
||||
local default = rename_default(item.name, attempt) |
||||
local value, event = ya.input({ |
||||
title = "New name", |
||||
value = default, |
||||
pos = { "center", w = 60 }, |
||||
}) |
||||
if event ~= 1 then |
||||
-- User cancelled the input — skip this item |
||||
break |
||||
end |
||||
|
||||
local new_dest = cwd:join(value) |
||||
if fs.cha(new_dest) == nil then |
||||
-- No conflict — add and continue |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(new_dest), |
||||
overwrite = false, |
||||
replace = false, |
||||
} |
||||
break |
||||
else |
||||
-- New name also conflicts — show the conflict dialog again |
||||
local sub_choice = pick_conflict(value) |
||||
if sub_choice == nil or sub_choice == 5 then |
||||
-- Cancel everything |
||||
dispatch(resolved) |
||||
return |
||||
elseif sub_choice == 0 then |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(new_dest), |
||||
overwrite = true, |
||||
replace = false, |
||||
} |
||||
break |
||||
elseif sub_choice == 1 then |
||||
merge_all = true |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(new_dest), |
||||
overwrite = true, |
||||
replace = false, |
||||
} |
||||
break |
||||
elseif sub_choice == 2 then |
||||
resolved[#resolved + 1] = { |
||||
from = tostring(item.url), |
||||
to = tostring(new_dest), |
||||
overwrite = true, |
||||
replace = true, |
||||
} |
||||
break |
||||
elseif sub_choice == 3 then |
||||
-- Rename again — increment attempt for different default |
||||
-- (loop continues) |
||||
elseif sub_choice == 4 then |
||||
-- Skip |
||||
break |
||||
end |
||||
end |
||||
end |
||||
|
||||
elseif choice == 4 then |
||||
-- Skip: do nothing |
||||
end |
||||
end |
||||
end |
||||
|
||||
-- Dispatch all resolved items |
||||
dispatch(resolved) |
||||
end |
||||
|
||||
return M |
||||
Loading…
Reference in new issue