diff --git a/nvim/init.lua b/nvim/init.lua index 178be7f..c6d1766 100644 --- a/nvim/init.lua +++ b/nvim/init.lua @@ -15,8 +15,7 @@ require("maps") require("bootstrap") require("dep") { - require("plugin.carbon") } -require("carbon").setup() +require("carbon").setup({}) diff --git a/nvim/lua/carbon/constants.lua b/nvim/lua/carbon/constants.lua new file mode 100644 index 0000000..be25df8 --- /dev/null +++ b/nvim/lua/carbon/constants.lua @@ -0,0 +1,6 @@ +return { + hl = vim.api.nvim_create_namespace('carbon'), + hl_tmp = vim.api.nvim_create_namespace('carbon:tmp'), + augroup = vim.api.nvim_create_augroup('carbon', { clear = false }), + directions = { left = 'h', right = 'l', up = 'k', down = 'j' }, +} diff --git a/nvim/lua/carbon/entry.lua b/nvim/lua/carbon/entry.lua new file mode 100644 index 0000000..b7a6b10 --- /dev/null +++ b/nvim/lua/carbon/entry.lua @@ -0,0 +1,180 @@ +local util = require('carbon.util') +local watcher = require('carbon.watcher') +local entry = {} + +entry.items = {} +entry.__index = entry +entry.__lt = function(a, b) + if a.is_directory and b.is_directory then + return string.lower(a.name) < string.lower(b.name) + elseif a.is_directory then + return true + elseif b.is_directory then + return false + end + + return string.lower(a.name) < string.lower(b.name) +end + +function entry.new(path, parent) + local raw_path = path == '' and '/' or path + local clean = string.gsub(raw_path, '/+$', '') + local lstat = select(2, pcall(vim.loop.fs_lstat, raw_path)) or {} + local is_executable = lstat.mode == 33261 + local is_directory = lstat.type == 'directory' + local is_symlink = lstat.type == 'link' and 1 + + if is_symlink then + local stat = select(2, pcall(vim.loop.fs_stat, raw_path)) + + if stat then + is_executable = lstat.mode == 33261 + is_directory = stat.type == 'directory' + is_symlink = 1 + else + is_symlink = 2 + end + end + + return setmetatable({ + raw_path = raw_path, + path = clean, + name = vim.fn.fnamemodify(clean, ':t'), + parent = parent, + is_directory = is_directory, + is_executable = is_executable, + is_symlink = is_symlink, + }, entry) +end + +function entry.find(path) + for _, children in pairs(entry.items) do + for _, child in ipairs(children) do + if child.path == path then + return child + end + end + end +end + +function entry:synchronize(paths) + if not self.is_directory then + return + end + + paths = paths or {} + + if paths[self.path] then + paths[self.path] = nil + + local all_paths = {} + local current_paths = {} + local previous_paths = {} + local previous_children = entry.items[self.path] or {} + + self:set_children(nil) + + for _, previous in ipairs(previous_children) do + all_paths[previous.path] = true + previous_paths[previous.path] = previous + end + + for _, current in ipairs(self:children()) do + all_paths[current.path] = true + current_paths[current.path] = current + end + + for path in pairs(all_paths) do + local current = current_paths[path] + local previous = previous_paths[path] + + if previous and current then + if current.is_directory then + current:synchronize(paths) + end + elseif previous then + previous:terminate() + end + end + elseif self:has_children() then + for _, child in ipairs(self:children()) do + if child.is_directory then + child:synchronize(paths) + end + end + end +end + +function entry:terminate() + watcher.release(self.path) + + if self:has_children() then + for _, child in ipairs(self:children()) do + child:terminate() + end + + self:set_children(nil) + end + + if self.parent and self.parent:has_children() then + self.parent:set_children(vim.tbl_filter(function(sibling) + return sibling.path ~= self.path + end, entry.items[self.parent.path])) + end +end + +function entry:children() + if self.is_directory and not self:has_children() then + self:set_children(self:get_children()) + end + + return entry.items[self.path] or {} +end + +function entry:has_children() + return entry.items[self.path] and true or false +end + +function entry:set_children(children) + entry.items[self.path] = children +end + +function entry:get_children() + local entries = {} + local handle = vim.loop.fs_scandir(self.raw_path) + + if type(handle) == 'userdata' then + local function iterator() + return vim.loop.fs_scandir_next(handle) + end + + for name in iterator do + local absolute_path = self.path .. '/' .. name + local relative_path = vim.fn.fnamemodify(absolute_path, ':.') + + if not util.is_excluded(relative_path) then + entries[#entries + 1] = entry.new(absolute_path, self) + end + end + + table.sort(entries) + end + + return entries +end + +function entry:highlight_group() + if self.is_symlink == 1 then + return 'CarbonSymlink' + elseif self.is_symlink == 2 then + return 'CarbonBrokenSymlink' + elseif self.is_directory then + return 'CarbonDir' + elseif self.is_executable then + return 'CarbonExe' + else + return 'CarbonFile' + end +end + +return entry diff --git a/nvim/lua/carbon/health.lua b/nvim/lua/carbon/health.lua new file mode 100644 index 0000000..8977d5a --- /dev/null +++ b/nvim/lua/carbon/health.lua @@ -0,0 +1,79 @@ +local util = require('carbon.util') +local view = require('carbon.view') +local watcher = require('carbon.watcher') +local health = {} + +local function sort_names(a, b) + return string.lower(a) < string.lower(b) +end + +local function sort_paths(a, b) + local a_is_directory = util.is_directory(a) + local b_is_directory = util.is_directory(b) + + if a_is_directory and b_is_directory then + return sort_names(a, b) + elseif a_is_directory then + return true + elseif b_is_directory then + return false + end + + return sort_names(a, b) +end + +function health.check() + health.report_views() + health.report_listeners() + health.report_events() +end + +function health.report_views() + vim.health.report_start('view::active') + + local view_roots = vim.tbl_map(function(item) + return item.root + end, view.items) + + table.sort(view_roots) + + for _, root in ipairs(view_roots) do + vim.health.report_info(root.path) + end +end + +function health.report_events() + vim.health.report_start('watcher::events') + + local names = vim.tbl_keys(watcher.events) + + table.sort(names, sort_names) + + for _, name in ipairs(names) do + local callback_count = #vim.tbl_keys(watcher.events[name] or {}) + local reporter = callback_count == 0 and 'report_warn' or 'report_info' + + vim.health[reporter]( + string.format( + '%d %s attached to %s', + callback_count, + callback_count == 1 and 'handler' or 'handlers', + name + ) + ) + end +end + +function health.report_listeners() + vim.health.report_start('watcher::listeners') + + local paths = vim.tbl_keys(watcher.listeners) + + table.sort(paths, sort_paths) + + for _, path in ipairs(paths) do + vim.health.report_info(path) + end +end + +return health diff --git a/nvim/lua/carbon/init.lua b/nvim/lua/carbon/init.lua new file mode 100644 index 0000000..ad00e5e --- /dev/null +++ b/nvim/lua/carbon/init.lua @@ -0,0 +1,378 @@ +local util = require('carbon.util') +local watcher = require('carbon.watcher') +local settings = require('carbon.settings') +local view = require('carbon.view') +local carbon = {} + +function carbon.setup(user_settings) + if type(user_settings) ~= 'table' then + user_settings = {} + end + + if not vim.g.carbon_initialized then + if type(user_settings) == 'function' then + user_settings(settings) + elseif type(user_settings) == 'table' then + local next = vim.tbl_deep_extend('force', settings, user_settings) + + for setting, value in pairs(next) do + settings[setting] = value + end + end + + if type(user_settings.highlights) == 'table' then + settings.highlights = + vim.tbl_extend('force', settings.highlights, user_settings.highlights) + end + + local argv = vim.fn.argv() + local open = argv[1] and vim.fn.fnamemodify(argv[1], ':p') or vim.loop.cwd() + local command_opts = { bang = true, nargs = '?', complete = 'dir' } + + watcher.on('carbon:synchronize', function(_, path) + view.resync(path) + end) + + util.command('Carbon', carbon.explore, command_opts) + util.command('Rcarbon', carbon.explore_right, command_opts) + util.command('Lcarbon', carbon.explore_left, command_opts) + util.command('Fcarbon', carbon.explore_float, command_opts) + util.command('ToggleSidebarCarbon', carbon.toggle_sidebar, command_opts) + + util.autocmd('SessionLoadPost', carbon.session_load_post, { pattern = '*' }) + util.autocmd('WinResized', carbon.win_resized, { pattern = '*' }) + + if settings.open_on_dir then + util.autocmd('BufWinEnter', carbon.explore_buf_dir, { pattern = '*' }) + end + + --if settings.sync_on_cd then + util.autocmd('DirChanged', carbon.cd, { pattern = 'global' }) + --end + + if not settings.keep_netrw then + vim.g.loaded_netrw = 1 + vim.g.loaded_netrwPlugin = 1 + + pcall(vim.api.nvim_del_augroup_by_name, 'FileExplorer') + pcall(vim.api.nvim_del_augroup_by_name, 'Network') + + util.command('Explore', carbon.explore, command_opts) + util.command('Rexplore', carbon.explore_right, command_opts) + util.command('Lexplore', carbon.explore_left, command_opts) + util.command('ToggleSidebarExplore', carbon.toggle_sidebar, command_opts) + end + + for action in pairs(settings.defaults.actions) do + vim.keymap.set('', util.plug(action), carbon[action]) + end + + if type(settings.highlights) == 'table' then + for group, properties in pairs(settings.highlights) do + util.highlight(group, properties) + end + end + + --[[ + if + vim.fn.has('vim_starting') + and settings.auto_open + and util.is_directory(open) + then + view.activate({ path = open }) + end + --]] + + vim.g.carbon_initialized = true + end +end + +function carbon.win_resized() + if vim.api.nvim_win_is_valid(view.sidebar.origin) then + local window_width = vim.api.nvim_win_get_width(view.sidebar.origin) + + if window_width ~= settings.sidebar_width then + vim.api.nvim_win_set_width(view.sidebar.origin, settings.sidebar_width) + end + end +end + +function carbon.session_load_post(event) + if util.is_directory(event.file) then + local window_id = util.bufwinid(event.buf) + local window_width = vim.api.nvim_win_get_width(window_id) + local is_sidebar = window_width == settings.sidebar_width + + view.activate({ path = event.file }) + view.execute(function(ctx) + ctx.view:show() + end) + + if is_sidebar then + local neighbor = util.tbl_find( + util.window_neighbors(window_id, { 'left', 'right' }), + function(neighbor) + return neighbor.target + end + ) + + if neighbor then + view.sidebar = neighbor + end + end + end +end + +function carbon.toggle_recursive() + view.execute(function(ctx) + if ctx.cursor.line.entry.is_directory then + local function toggle_recursive(target, value) + if target.is_directory then + ctx.view:set_path_attr(target.path, 'open', value) + + if target:has_children() then + for _, child in ipairs(target:children()) do + toggle_recursive(child, value) + end + end + end + end + + toggle_recursive( + ctx.cursor.line.entry, + not ctx.view:get_path_attr(ctx.cursor.line.entry.path, 'open') + ) + + ctx.view:update() + ctx.view:render() + end + end) +end + +function carbon.edit() + view.execute(function(ctx) + if ctx.cursor.line.entry.is_directory then + local open = ctx.view:get_path_attr(ctx.cursor.line.entry.path, 'open') + + ctx.view:set_path_attr(ctx.cursor.line.entry.path, 'open', not open) + ctx.view:update() + ctx.view:render() + else + view.handle_sidebar_or_float() + vim.cmd.edit({ + ctx.cursor.line.entry.path, + mods = { keepalt = #vim.fn.getreg('#') ~= 0 }, + }) + end + end) +end + +function carbon.split() + view.execute(function(ctx) + if not ctx.cursor.line.entry.is_directory then + if vim.w.carbon_fexplore_window then + vim.api.nvim_win_close(0, 1) + end + + view.handle_sidebar_or_float() + vim.cmd.split(ctx.cursor.line.entry.path) + end + end) +end + +function carbon.vsplit() + view.execute(function(ctx) + if not ctx.cursor.line.entry.is_directory then + if vim.w.carbon_fexplore_window then + vim.api.nvim_win_close(0, 1) + end + + view.handle_sidebar_or_float() + vim.cmd.vsplit(ctx.cursor.line.entry.path) + end + end) +end + +function carbon.up() + view.execute(function(ctx) + if ctx.view:up() then + ctx.view:update() + ctx.view:render() + util.cursor(1, 1) + end + end) +end + +function carbon.reset() + view.execute(function(ctx) + if ctx.view:reset() then + ctx.view:update() + ctx.view:render() + util.cursor(1, 1) + end + end) +end + +function carbon.down() + view.execute(function(ctx) + if ctx.view:down() then + ctx.view:update() + ctx.view:render() + util.cursor(1, 1) + end + end) +end + +function carbon.cd(path) + view.execute(function(ctx) + local destination = path and path.file or path or vim.v.event.cwd + + if ctx.view:cd(destination) then + ctx.view:update() + ctx.view:render() + util.cursor(1, 1) + end + end) +end + +function carbon.explore(options_param) + local options = options_param or {} + local path = + util.explore_path(options.fargs and options.fargs[1] or '', view.current()) + + view.activate({ path = path, reveal = options.bang }) +end + +function carbon.toggle_sidebar(options) + local current_win = vim.api.nvim_get_current_win() + + if vim.api.nvim_win_is_valid(view.sidebar.origin) then + vim.api.nvim_win_close(view.sidebar.origin, 1) + else + local explore_options = vim.tbl_extend( + 'force', + options or {}, + { sidebar = settings.sidebar_position } + ) + + carbon.explore_sidebar(explore_options) + + if not settings.sidebar_toggle_focus then + vim.api.nvim_set_current_win(current_win) + end + end +end + +function carbon.explore_sidebar(options_param) + local options = options_param or {} + local sidebar = options.sidebar or settings.sidebar_position + local path = + util.explore_path(options.fargs and options.fargs[1] or '', view.current()) + + view.activate({ path = path, reveal = options.bang, sidebar = sidebar }) +end + +function carbon.explore_left(options_param) + if view.sidebar.position ~= 'left' then + view.close_sidebar() + end + + carbon.explore_sidebar( + vim.tbl_extend('force', options_param or {}, { sidebar = 'left' }) + ) +end + +function carbon.explore_right(options_param) + if view.sidebar.position ~= 'right' then + view.close_sidebar() + end + + carbon.explore_sidebar( + vim.tbl_extend('force', options_param or {}, { sidebar = 'right' }) + ) +end + +function carbon.explore_float(options_param) + local options = options_param or {} + local path = + util.explore_path(options.fargs and options.fargs[1] or '', view.current()) + + view.activate({ path = path, reveal = options.bang, float = true }) +end + +function carbon.explore_buf_dir(params) + if vim.bo.filetype == 'carbon.explorer' then + return + end + + if params and params.file and util.is_directory(params.file) then + view.activate({ path = params.file }) + view.execute(function(ctx) + ctx.view:show() + end) + end +end + +function carbon.quit() + if #vim.api.nvim_list_wins() > 1 then + vim.api.nvim_win_close(0, 1) + elseif #vim.api.nvim_list_bufs() > 1 then + pcall(vim.cmd.bprevious) + end +end + +function carbon.create() + view.execute(function(ctx) + ctx.view:create() + end) +end + +function carbon.delete() + view.execute(function(ctx) + ctx.view:delete() + end) +end + +function carbon.move() + view.execute(function(ctx) + ctx.view:move() + end) +end + +function carbon.close_parent() + view.execute(function(ctx) + local count = 0 + local lines = { unpack(ctx.view:current_lines(), 2) } + local entry = ctx.cursor.line.entry + local line + + while count < vim.v.count1 do + line = util.tbl_find(lines, function(current) + return current.entry == entry.parent + or vim.tbl_contains(current.path, entry.parent) + end) + + if line then + count = count + 1 + entry = line.path[1] and line.path[1].parent or line.entry + + ctx.view:set_path_attr(entry.path, 'open', false) + else + break + end + end + + line = util.tbl_find(lines, function(current) + return current.entry == entry or vim.tbl_contains(current.path, entry) + end) + + if line then + vim.fn.cursor(line.lnum, (line.depth + 1) * 2 + 1) + end + + ctx.view:update() + ctx.view:render() + end) +end + +return carbon diff --git a/nvim/lua/carbon/settings.lua b/nvim/lua/carbon/settings.lua new file mode 100644 index 0000000..f088eee --- /dev/null +++ b/nvim/lua/carbon/settings.lua @@ -0,0 +1,82 @@ +local defaults = { + sync_pwd = false, + compress = true, + auto_open = true, + keep_netrw = false, + file_icons = pcall(require, 'nvim-web-devicons'), + sync_on_cd = not vim.opt.autochdir:get(), + sync_delay = 20, + open_on_dir = true, + auto_reveal = false, + sidebar_width = 30, + sidebar_toggle_focus = true, + sidebar_position = 'left', + exclude = { + '~$', + '#$', + '%.git$', + '%.bak$', + '%.rbc$', + '%.class$', + '%.sw[a-p]$', + '%.py[cod]$', + '%.Trashes$', + '%.DS_Store$', + 'Thumbs%.db$', + '__pycache__', + 'node_modules', + }, + indicators = { + expand = '+', + collapse = '-', + }, + flash = { + delay = 50, + duration = 500, + }, + float_settings = function() + local columns = vim.opt.columns:get() + local rows = vim.opt.lines:get() + local width = math.min(40, math.floor(columns * 0.9)) + local height = math.min(20, math.floor(rows * 0.9)) + + return { + relative = 'editor', + style = 'minimal', + border = 'rounded', + width = width, + height = height, + col = math.floor(columns / 2 - width / 2), + row = math.floor(rows / 2 - height / 2 - 2), + } + end, + actions = { + up = '[', + down = ']', + quit = 'q', + edit = '', + move = 'm', + reset = 'u', + split = { '', '' }, + vsplit = '', + create = { 'c', '%' }, + delete = 'd', + close_parent = '-', + toggle_recursive = '!', + }, + highlights = { + CarbonDir = { link = 'Directory' }, + CarbonFile = { link = 'Text' }, + CarbonExe = { link = '@function.builtin' }, + CarbonSymlink = { link = '@include' }, + CarbonBrokenSymlink = { link = 'DiagnosticError' }, + CarbonIndicator = { fg = 'Gray', ctermfg = 'DarkGray', bold = true }, + CarbonFloat = { bg = '#111111', ctermbg = 'black' }, + CarbonFloatBorder = { link = 'CarbonFloat' }, + CarbonDanger = { link = 'Error' }, + CarbonPending = { link = 'Search' }, + CarbonFlash = { link = 'Visual' }, + }, +} + +return vim.tbl_extend('force', vim.deepcopy(defaults), { defaults = defaults }) diff --git a/nvim/lua/carbon/util.lua b/nvim/lua/carbon/util.lua new file mode 100644 index 0000000..a04ac38 --- /dev/null +++ b/nvim/lua/carbon/util.lua @@ -0,0 +1,323 @@ +local constants = require('carbon.constants') +local settings = require('carbon.settings') +local util = {} + +function util.explore_path(path, current_view) + path = string.gsub(path, '%s', '') + + if path == '' then + path = vim.loop.cwd() + end + + if not vim.startswith(path, '/') then + local base_path = current_view and current_view.root.path or vim.loop.cwd() + + path = string.format('%s/%s', base_path, path) + end + + return string.gsub(vim.fn.simplify(path), '/+$', '') +end + +function util.resolve(path) + return string.gsub( + vim.fn.fnamemodify(vim.fs.normalize(path), ':p'), + '/+$', + '' + ) +end + +function util.is_excluded(path) + if settings.exclude then + for _, pattern in ipairs(settings.exclude) do + if string.find(path, pattern) then + return true + end + end + end + + return false +end + +function util.cursor(row, col) + return vim.api.nvim_win_set_cursor(0, { row, col - 1 }) +end + +function util.is_directory(path) + return (vim.loop.fs_stat(path) or {}).type == 'directory' +end + +function util.plug(name) + return string.format('(carbon-%s)', string.gsub(name, '_', '-')) +end + +function util.tbl_key(tbl, item) + for key, tbl_item in pairs(tbl) do + if tbl_item == item then + return key + end + end +end + +function util.tbl_some(tbl, callback) + for key, value in pairs(tbl) do + if callback(value, key) then + return true + end + end + + return false +end + +function util.tbl_find(tbl, callback) + for key, value in pairs(tbl) do + if callback(value, key) then + return value, key + end + end +end + +function util.tbl_except(tbl, keys) + local result = {} + + for key, value in pairs(tbl) do + if not vim.tbl_contains(keys, key) then + result[key] = value + end + end + + return result +end + +function util.autocmd(event, cmd_or_callback, opts) + return vim.api.nvim_create_autocmd( + event, + vim.tbl_extend('force', { + group = constants.augroup, + callback = cmd_or_callback, + }, opts or {}) + ) +end + +function util.clear_autocmd(event, opts) + return vim.api.nvim_clear_autocmds(vim.tbl_extend('force', { + group = constants.augroup, + event = event, + }, opts or {})) +end + +function util.command(lhs, rhs, options) + return vim.api.nvim_create_user_command(lhs, rhs, options or {}) +end + +function util.highlight(group, opts) + local merged = vim.tbl_extend('force', { default = true }, opts or {}) + + vim.api.nvim_set_hl(0, group, merged) +end + +function util.confirm(options) + local finished = false + local actions = {} + local mappings = {} + local lines = {} + + local function finish(label, immediate) + local function handler() + if finished then + return nil + end + + finished = true + local callback = actions[label] and actions[label].callback + + if type(callback) == 'function' then + callback() + end + + vim.cmd.close() + end + + if not immediate then + return handler + end + + handler() + end + + for ascii = 32, 127 do + if + ascii < 48 + and ascii > 57 + and not vim.tbl_contains({ 38, 40, 74, 75, 106, 107 }, ascii) + then + mappings[#mappings + 1] = { 'n', string.char(ascii), '' } + end + end + + for _, action in ipairs(options.actions) do + actions[action.label] = action + + if action.shortcut then + mappings[#mappings + 1] = { 'n', action.shortcut, finish(action.label) } + lines[#lines + 1] = ' [' .. action.shortcut .. '] ' .. action.label + else + lines[#lines + 1] = ' ' .. action.label + end + end + + mappings[#mappings + 1] = { 'n', '', finish('cancel') } + mappings[#mappings + 1] = { + 'n', + '', + function() + finish(string.sub(vim.fn.getline('.'), 6), true) + end, + } + + local buf = util.create_scratch_buf({ + modifiable = false, + lines = lines, + mappings = mappings, + autocmds = { + BufLeave = finish('cancel'), + CursorMoved = function() + util.cursor(vim.fn.line('.'), 3) + end, + }, + }) + + local win = vim.api.nvim_open_win(buf, true, { + relative = 'win', + anchor = 'NW', + border = 'single', + style = 'minimal', + row = options.row or vim.fn.line('.'), + col = options.col or vim.fn.col('.'), + height = #lines, + width = 1 + math.max(unpack(vim.tbl_map(function(line) + return #line + end, lines))), + }) + + util.cursor(1, 3) + vim.api.nvim_win_set_option(win, 'cursorline', true) + util.set_winhl(win, { + Normal = options.highlight or 'CarbonIndicator', + FloatBorder = options.highlight or 'Normal', + CursorLine = options.highlight or 'Normal', + }) +end + +function util.bufwinid(buf) + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == buf then + return win + end + end +end + +function util.find_buf_by_name(name) + return util.tbl_find(vim.api.nvim_list_bufs(), function(bufnr) + return name == vim.api.nvim_buf_get_name(bufnr) + end) +end + +function util.create_scratch_buf(options) + options = options or {} + local found = util.find_buf_by_name(options.name) + local buf = found or vim.api.nvim_create_buf(false, true) + local buffer_options = vim.tbl_extend('force', { + bufhidden = 'wipe', + buftype = 'nofile', + swapfile = false, + }, util.tbl_except(options, { 'name', 'lines', 'mappings', 'autocmds' })) + + if options.name then + vim.api.nvim_buf_set_name(buf, options.name == '' and '/' or options.name) + end + + if options.lines then + vim.api.nvim_buf_set_lines(buf, 0, -1, 1, options.lines) + vim.api.nvim_buf_set_option(buf, 'modified', false) + end + + if options.mappings then + util.set_buf_mappings(buf, options.mappings) + end + + if options.autocmds then + util.set_buf_autocmds(buf, options.autocmds) + end + + for option, value in pairs(buffer_options) do + vim.api.nvim_buf_set_option(buf, option, value) + end + + return buf +end + +function util.set_buf_mappings(buf, mappings) + for _, mapping in ipairs(mappings) do + vim.keymap.set( + mapping[1], + mapping[2], + mapping[3], + vim.tbl_extend('force', mapping[4] or {}, { buffer = buf }) + ) + end +end + +function util.set_buf_autocmds(buf, autocmds) + for autocmd, rhs in pairs(autocmds) do + util.autocmd(autocmd, rhs, { buffer = buf }) + end +end + +function util.set_winhl(win, highlights) + local winhls = {} + + for source, target in pairs(highlights) do + winhls[#winhls + 1] = source .. ':' .. target + end + + vim.api.nvim_win_set_option(win, 'winhl', table.concat(winhls, ',')) +end + +function util.clear_extmarks(buf, ...) + local extmarks = vim.api.nvim_buf_get_extmarks(buf, constants.hl, ...) + + for _, extmark in ipairs(extmarks) do + vim.api.nvim_buf_del_extmark(buf, constants.hl, extmark[1]) + end +end + +function util.add_highlight(buf, ...) + vim.api.nvim_buf_add_highlight(buf, constants.hl, ...) +end + +function util.window_neighbors(window_id, sides) + local original_window = vim.api.nvim_get_current_win() + local result = {} + + for _, side in ipairs(sides or {}) do + vim.api.nvim_set_current_win(window_id) + vim.cmd.wincmd(constants.directions[side]) + + local side_id = vim.api.nvim_get_current_win() + local result_id = window_id ~= side_id and side_id or nil + + if result_id then + result[#result + 1] = { + origin = window_id, + position = side, + target = result_id, + } + end + end + + vim.api.nvim_set_current_win(original_window) + + return result +end + +return util diff --git a/nvim/lua/carbon/view.lua b/nvim/lua/carbon/view.lua new file mode 100644 index 0000000..e743b62 --- /dev/null +++ b/nvim/lua/carbon/view.lua @@ -0,0 +1,945 @@ +local util = require('carbon.util') +local entry = require('carbon.entry') +local watcher = require('carbon.watcher') +local settings = require('carbon.settings') +local constants = require('carbon.constants') +local view = {} + +view.__index = view +view.sidebar = { origin = -1, target = -1 } +view.float = { origin = -1, target = -1 } +view.items = {} +view.resync_paths = {} + +local function create_leave(ctx) + vim.cmd.stopinsert() + ctx.view:set_path_attr(ctx.target.path, 'compressible', ctx.prev_compressible) + util.cursor(ctx.target_line.lnum, 1) + vim.keymap.del('i', '', { buffer = 0 }) + vim.keymap.del('i', '', { buffer = 0 }) + util.clear_autocmd('CursorMovedI', { buffer = 0 }) + ctx.view:update() + ctx.view:render() +end + +local function create_confirm(ctx) + return function() + local text = vim.trim(string.sub(vim.fn.getline('.'), ctx.edit_col)) + local name = vim.fn.fnamemodify(text, ':t') + local parent_directory = ctx.target.path + .. '/' + .. vim.trim(vim.fn.fnamemodify(text, ':h')) + + vim.fn.mkdir(parent_directory, 'p') + + if name ~= '' then + vim.fn.writefile({}, parent_directory .. '/' .. name) + end + + create_leave(ctx) + view.resync(vim.fn.fnamemodify(parent_directory, ':h')) + end +end + +local function create_cancel(ctx) + return function() + ctx.view:set_path_attr(ctx.target.path, 'open', ctx.prev_open) + create_leave(ctx) + end +end + +local function create_insert_move(ctx) + return function() + local text = ctx.edit_prefix + .. vim.trim(string.sub(vim.fn.getline('.'), ctx.edit_col)) + local last_slash_col = vim.fn.strridx(text, '/') + 1 + + vim.api.nvim_buf_set_lines(0, ctx.edit_lnum, ctx.edit_lnum + 1, 1, { text }) + util.clear_extmarks(0, { ctx.edit_lnum, 0 }, { ctx.edit_lnum, -1 }, {}) + util.add_highlight(0, 'CarbonDir', ctx.edit_lnum, 0, last_slash_col) + util.add_highlight(0, 'CarbonFile', ctx.edit_lnum, last_slash_col, -1) + util.cursor(ctx.edit_lnum + 1, math.max(ctx.edit_col, vim.fn.col('.'))) + end +end + +function view.file_icons() + if settings.file_icons then + local ok, module = pcall(require, 'nvim-web-devicons') + + if ok then + return module + end + end +end + +function view.find(path) + local resolved = util.resolve(path) + + return util.tbl_find(view.items, function(target_view) + return target_view.root.path == resolved + end) +end + +function view.get(path) + local found_view = view.find(path) + + if found_view then + return found_view + end + + local index = #view.items + 1 + local resolved = util.resolve(path) + local instance = setmetatable({ + index = index, + initial = resolved, + states = {}, + root = entry.new(resolved), + }, view) + + view.items[index] = instance + + return instance +end + +function view.activate(options_param) + local options = options_param or {} + local original_window = vim.api.nvim_get_current_win() + local current_view = (options.path and view.get(options.path)) + or view.current() + or view.get(vim.loop.cwd()) + + if options.reveal or settings.auto_reveal then + current_view:expand_to_path(vim.fn.expand('%')) + end + + if options.sidebar then + if vim.api.nvim_win_is_valid(view.sidebar.origin) then + vim.api.nvim_set_current_win(view.sidebar.origin) + else + local split = options.sidebar == 'right' and 'botright' or 'topleft' + local target_side = options.sidebar == 'right' and 'left' or 'right' + + vim.cmd.split({ mods = { vertical = true, split = split } }) + + local origin_id = vim.api.nvim_get_current_win() + local neighbor = util.window_neighbors(origin_id, { target_side })[1] + local target = neighbor and neighbor.target or original_window + + view.sidebar = { + position = options.sidebar, + origin = origin_id, + target = target, + } + end + + vim.api.nvim_win_set_width(view.sidebar.origin, settings.sidebar_width) + vim.api.nvim_win_set_buf(view.sidebar.origin, current_view:buffer()) + elseif options.float then + local float_settings = settings.float_settings + or settings.defaults.float_settings + + float_settings = type(float_settings) == 'function' and float_settings() + or vim.deepcopy(float_settings) + + view.float = { + origin = vim.api.nvim_open_win(current_view:buffer(), 1, float_settings), + target = original_window, + } + + vim.api.nvim_win_set_option( + view.float.origin, + 'winhl', + 'FloatBorder:CarbonFloatBorder,Normal:CarbonFloat' + ) + else + vim.api.nvim_win_set_buf(0, current_view:buffer()) + end +end + +function view.close_sidebar() + if vim.api.nvim_win_is_valid(view.sidebar.origin) then + vim.api.nvim_win_close(view.sidebar.origin, true) + end + + view.sidebar = { origin = -1, target = -1 } +end + +function view.close_float() + if vim.api.nvim_win_is_valid(view.float.origin) then + vim.api.nvim_win_close(view.float.origin, true) + end + + view.float = { origin = -1, target = -1 } +end + +function view.handle_sidebar_or_float() + local current_window = vim.api.nvim_get_current_win() + + if current_window == view.sidebar.origin then + if vim.api.nvim_win_is_valid(view.sidebar.target) then + vim.api.nvim_set_current_win(view.sidebar.target) + else + local split = view.sidebar.position == 'right' and 'topleft' or 'botright' + local target_side = view.sidebar.position == 'right' and 'left' or 'right' + local neighbor = + util.window_neighbors(view.sidebar.origin, { target_side })[1] + + if neighbor then + view.sidebar.target = neighbor.target + vim.api.nvim_set_current_win(neighbor.target) + else + vim.cmd.split({ mods = { vertical = true, split = split } }) + + view.sidebar.target = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_width(view.sidebar.origin, settings.sidebar_width) + end + end + elseif current_window == view.float.origin then + view.close_float() + end +end + +function view.current() + local bufnr = vim.api.nvim_get_current_buf() + local ref = select(2, pcall(vim.api.nvim_buf_get_var, bufnr, 'carbon')) + + return ref and view.items[ref.index] or false +end + +function view.execute(callback) + local current_view = view.current() + + if current_view then + return callback({ cursor = current_view:cursor(), view = current_view }) + end +end + +function view.resync(path) + view.resync_paths[path] = true + + if view.resync_timer and not view.resync_timer:is_closing() then + view.resync_timer:close() + end + + view.resync_timer = vim.defer_fn(function() + for _, current_view in ipairs(view.items) do + current_view.root:synchronize(view.resync_paths) + current_view:update() + current_view:render() + end + + if not view.resync_timer:is_closing() then + view.resync_timer:close() + end + + view.resync_timer = nil + view.resync_paths = {} + end, settings.sync_delay) +end + +function view:expand_to_path(path) + local resolved = util.resolve(path) + + if vim.startswith(resolved, self.root.path) then + local dirs = vim.split(string.sub(resolved, #self.root.path + 2), '/') + local current = self.root + + for _, dir in ipairs(dirs) do + current:children() + + current = entry.find(string.format('%s/%s', current.path, dir)) + + if current then + self:set_path_attr(current.path, 'open', true) + else + break + end + end + + if current and current.path == resolved then + self.flash = current + + self:update() + + return true + end + + return false + end +end + +function view:get_path_attr(path, attr) + local state = self.states[path] + local value = state and state[attr] + + if attr == 'compressible' and value == nil then + return true + end + + return value +end + +function view:set_path_attr(path, attr, value) + if not self.states[path] then + self.states[path] = {} + end + + self.states[path][attr] = value + + return value +end + +function view:buffers() + return vim.tbl_filter(function(bufnr) + local ref = select(2, pcall(vim.api.nvim_buf_get_var, bufnr, 'carbon')) + + return ref and ref.index == self.index + end, vim.api.nvim_list_bufs()) +end + +function view:update() + self.cached_lines = nil +end + +function view:render() + local cursor + local lines = {} + local hls = {} + + for lnum, line_data in ipairs(self:current_lines()) do + lines[#lines + 1] = line_data.line + + if self.flash and self.flash.path == line_data.entry.path then + cursor = { lnum = lnum, col = 1 + (line_data.depth + 1) * 2 } + end + + for _, hl in ipairs(line_data.highlights) do + hls[#hls + 1] = { hl[1], lnum - 1, hl[2], hl[3] } + end + end + + local buf = self:buffer() + local current_mode = string.lower(vim.api.nvim_get_mode().mode) + + vim.api.nvim_buf_clear_namespace(buf, constants.hl, 0, -1) + vim.api.nvim_buf_set_option(buf, 'modifiable', true) + vim.api.nvim_buf_set_lines(buf, 0, -1, 1, lines) + vim.api.nvim_buf_set_option(buf, 'modified', false) + + if not string.find(current_mode, 'i') then + vim.api.nvim_buf_set_option(buf, 'modifiable', false) + end + + for _, hl in ipairs(hls) do + vim.api.nvim_buf_add_highlight( + buf, + constants.hl, + hl[1], + hl[2], + hl[3], + hl[4] + ) + end + + if cursor then + util.cursor(cursor.lnum, cursor.col) + + if settings.flash then + vim.defer_fn(function() + self:focus_flash( + settings.flash.duration, + 'CarbonFlash', + { cursor.lnum - 1, cursor.col - 1 }, + { cursor.lnum - 1, -1 } + ) + end, settings.flash.delay) + end + end + + self.flash = nil +end + +function view:focus_flash(duration, group, start, finish) + local buf = self:buffer() + + vim.highlight.range(buf, constants.hl_tmp, group, start, finish, {}) + + vim.defer_fn(function() + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_clear_namespace(buf, constants.hl_tmp, 0, -1) + end + end, duration) +end + +function view:buffer() + local buffers = self:buffers() + + if buffers[1] then + return buffers[1] + end + + local mappings = { + { 'n', 'i', '' }, + { 'n', 'I', '' }, + { 'n', 'o', '' }, + { 'n', 'O', '' }, + } + + for action, mapping in pairs(settings.actions or {}) do + if type(mapping) == 'string' then + mapping = { mapping } + end + + if type(mapping) == 'table' then + for _, key in ipairs(mapping) do + mappings[#mappings + 1] = + { 'n', key, util.plug(action), { nowait = true } } + end + end + end + + local buffer = util.create_scratch_buf({ + name = self.root.path, + filetype = 'carbon.explorer', + modifiable = false, + modified = false, + bufhidden = 'wipe', + mappings = mappings, + autocmds = { + BufHidden = function() + self:hide() + end, + BufWinEnter = function() + self:show() + end, + }, + }) + + vim.api.nvim_buf_set_var( + buffer, + 'carbon', + { index = self.index, path = self.root.path } + ) + + return buffer +end + +function view:hide() -- luacheck:ignore unused argument self + vim.opt_local.wrap = vim.opt_global.wrap:get() + vim.opt_local.spell = vim.opt_global.spell:get() + vim.opt_local.fillchars = vim.opt_global.fillchars:get() + + view.sidebar = { origin = -1, target = -1 } + view.float = { origin = -1, target = -1 } +end + +function view:show() + vim.opt_local.wrap = false + vim.opt_local.spell = false + vim.opt_local.fillchars = { eob = ' ' } + + self:render() +end + +function view:up(count) + local parents = self:parents(count) + local destination = parents[#parents] + + if destination and self:switch_to_existing_view(destination.path) then + return true + end + + for idx, parent_entry in ipairs(parents) do + self:set_path_attr(self.root.path, 'open', true) + self:set_root(parent_entry, { rename = idx == #parents }) + end + + return #parents ~= 0 +end + +function view:reset() + return self:cd(self.initial) +end + +function view:cd(path) + if path == self.root.path then + return false + elseif vim.startswith(self.root.path, path) then + local new_depth = select(2, string.gsub(path, '/', '')) + local current_depth = select(2, string.gsub(self.root.path, '/', '')) + + if current_depth - new_depth > 0 then + return self:up(current_depth - new_depth) + end + elseif self:switch_to_existing_view(path) then + return true + else + return self:set_root(entry.find(path) or entry.new(path)) + end +end + +function view:down(count) + local cursor = self:cursor() + local new_root = cursor.line.path[count or vim.v.count1] or cursor.line.entry + + if not new_root.is_directory then + new_root = new_root.parent + end + + if not new_root or new_root.path == self.root.path then + return false + end + + if self:switch_to_existing_view(new_root.path) then + return true + else + self:set_path_attr(self.root.path, 'open', true) + + return self:set_root(new_root) + end +end + +function view:set_root(target, options_param) + local options = options_param or {} + local is_cwd = self.root.path == vim.loop.cwd() + + if type(target) == 'string' then + target = entry.new(target) + end + + if target.path == self.root.path then + return false + end + + self.root = target + + if options.rename ~= false then + vim.api.nvim_buf_set_name(self:buffer(), self.root.raw_path) + end + + vim.api.nvim_buf_set_var( + self:buffer(), + 'carbon', + { index = self.index, path = self.root.path } + ) + + watcher.keep(function(path) + return util.tbl_some(view.items, function(current_view) + return vim.startswith(path, current_view.root.path) + end) + end) + + if settings.sync_pwd and is_cwd then + vim.api.nvim_set_current_dir(self.root.path) + end + + return true +end + +function view:current_lines() + if not self.cached_lines then + self.cached_lines = self:lines() + end + + return self.cached_lines +end + +function view:lines(input_target, lines, depth) + lines = lines or {} + depth = depth or 0 + local target = input_target or self.root + local expand_indicator = ' ' + local collapse_indicator = ' ' + local file_icons = view.file_icons() + + if type(settings.indicators) == 'table' then + expand_indicator = settings.indicators.expand or expand_indicator + collapse_indicator = settings.indicators.collapse or collapse_indicator + end + + if not input_target and #lines == 0 then + lines[#lines + 1] = { + lnum = 1, + depth = -1, + entry = self.root, + line = self.root.name .. '/', + highlights = { { 'CarbonDir', 0, -1 } }, + icon_width = 0, + path = {}, + } + + watcher.register(self.root.path) + end + + for _, child in ipairs(target:children()) do + local tmp = child + local hls = {} + local path = {} + local lnum = 1 + #lines + local indent = string.rep(' ', depth) + local is_empty = true + local indicator = '' + local path_suffix = '' + + if settings.compress then + while + tmp.is_directory + and #tmp:children() == 1 + and self:get_path_attr(tmp.path, 'compressible') + do + watcher.register(tmp.path) + + path[#path + 1] = tmp + tmp = tmp:children()[1] + end + end + + if tmp.is_directory then + watcher.register(tmp.path) + + is_empty = #tmp:children() == 0 + path_suffix = '/' + + if not is_empty and self:get_path_attr(tmp.path, 'open') then + indicator = collapse_indicator + elseif not is_empty then + indicator = expand_indicator + else + indent = indent .. ' ' + end + else + indent = indent .. ' ' + end + + local icon = '' + local icon_highlight + + if file_icons and settings.file_icons and not tmp.is_directory then + local info = { + file_icons.get_icon( + tmp.name .. path_suffix, + vim.fn.fnamemodify(tmp.name, ':e'), + { default = true } + ), + } + + icon = info[1] or ' ' + icon_highlight = info[2] + end + + local full_path = tmp.name .. path_suffix + local indent_end = #indent + local icon_width = #icon ~= 0 and #icon + 1 or 0 + local indicator_width = #indicator ~= 0 and #indicator + 1 or 0 + local path_start = indent_end + icon_width + indicator_width + local dir_path = table.concat( + vim.tbl_map(function(parent) + return parent.name + end, path), + '/' + ) + + if path[1] then + full_path = dir_path .. '/' .. full_path + end + + if indicator_width ~= 0 and not is_empty then + hls[#hls + 1] = + { 'CarbonIndicator', indent_end, indent_end + indicator_width } + end + + if icon and icon_highlight then + hls[#hls + 1] = + { icon_highlight, indent_end + indicator_width, path_start - 1 } + end + + local entries = { unpack(path) } + entries[#entries + 1] = tmp + + for _, current_entry in ipairs(entries) do + local part = current_entry.name .. '/' + local path_end = path_start + #part + local highlight_group = 'CarbonFile' + + if current_entry.is_symlink == 1 then + highlight_group = 'CarbonSymlink' + elseif current_entry.is_symlink == 2 then + highlight_group = 'CarbonBrokenSymlink' + elseif current_entry.is_directory then + highlight_group = 'CarbonDir' + elseif current_entry.is_executable then + highlight_group = 'CarbonExe' + end + + hls[#hls + 1] = { highlight_group, path_start, path_end } + path_start = path_end + end + + local line_prefix = indent + + if indicator_width ~= 0 then + line_prefix = line_prefix .. indicator .. ' ' + end + + if icon_width ~= 0 then + line_prefix = line_prefix .. icon .. ' ' + end + + lines[#lines + 1] = { + lnum = lnum, + depth = depth, + entry = tmp, + line = line_prefix .. full_path, + icon_width = icon_width, + highlights = hls, + path = path, + } + + if tmp.is_directory and self:get_path_attr(tmp.path, 'open') then + self:lines(tmp, lines, depth + 1) + end + end + + return lines +end + +function view:cursor(opts) + local options = opts or {} + local lines = self:current_lines() + local line = lines[vim.fn.line('.')] + local target = line.entry + local target_line + + if options.target_directory_only and not target.is_directory then + target = target.parent + end + + target = line.path[vim.v.count] or target + target_line = util.tbl_find(lines, function(current) + if current.entry.path == target.path then + return true + end + + return util.tbl_find(current.path, function(parent) + if parent.path == target.path then + return true + end + end) + end) + + return { line = line, target = target, target_line = target_line } +end + +function view:create() + local ctx = self:cursor({ target_directory_only = true }) + + ctx.view = self + ctx.compact = ctx.target.is_directory and #ctx.target:children() == 0 + ctx.prev_open = self:get_path_attr(ctx.target.path, 'open') + ctx.prev_compressible = self:get_path_attr(ctx.target.path, 'compressible') + + self:set_path_attr(ctx.target.path, 'open', true) + self:set_path_attr(ctx.target.path, 'compressible', false) + + if ctx.compact then + ctx.edit_prefix = ctx.line.line + ctx.edit_lnum = ctx.line.lnum - 1 + ctx.edit_col = #ctx.edit_prefix + 1 + ctx.init_end_lnum = ctx.edit_lnum + 1 + else + ctx.edit_prefix = string.rep(' ', ctx.target_line.depth + 2) + ctx.edit_lnum = ctx.target_line.lnum + #self:lines(ctx.target) + ctx.edit_col = #ctx.edit_prefix + 1 + ctx.init_end_lnum = ctx.edit_lnum + end + + self:update() + self:render() + util.autocmd('CursorMovedI', create_insert_move(ctx), { buffer = 0 }) + vim.keymap.set('i', '', create_confirm(ctx), { buffer = 0 }) + vim.keymap.set('i', '', create_cancel(ctx), { buffer = 0 }) + vim.cmd.startinsert({ bang = true }) + vim.api.nvim_buf_set_option(0, 'modifiable', true) + vim.api.nvim_buf_set_lines( + 0, + ctx.edit_lnum, + ctx.init_end_lnum, + 1, + { ctx.edit_prefix } + ) + util.cursor(ctx.edit_lnum + 1, ctx.edit_col) +end + +function view:delete() + local cursor = self:cursor() + local targets = vim.list_extend( + { unpack(cursor.line.path) }, + { cursor.line.entry } + ) + + local lnum_idx = cursor.line.lnum - 1 + local count = vim.v.count == 0 and #targets or vim.v.count1 + local path_idx = math.min(count, #targets) + local target = targets[path_idx] + local highlight = { + 'CarbonFile', + cursor.line.depth * 2 + 2 + cursor.line.icon_width, + lnum_idx, + } + + if targets[path_idx].path == self.root.path then + return + end + + if target.is_directory then + highlight[1] = 'CarbonDir' + end + + for idx = 1, path_idx - 1 do + highlight[2] = highlight[2] + #cursor.line.path[idx].name + 1 + end + + util.clear_extmarks(0, { lnum_idx, highlight[2] }, { lnum_idx, -1 }, {}) + util.add_highlight(0, 'CarbonDanger', lnum_idx, highlight[2], -1) + util.confirm({ + row = cursor.line.lnum, + col = highlight[2], + highlight = 'CarbonDanger', + actions = { + { + label = 'delete', + shortcut = 'D', + callback = function() + local result = + vim.fn.delete(target.path, target.is_directory and 'rf' or '') + + if result == -1 then + vim.api.nvim_echo({ + { 'Failed to delete: ', 'CarbonDanger' }, + { vim.fn.fnamemodify(target.path, ':.'), 'CarbonIndicator' }, + }, false, {}) + else + view.resync(vim.fn.fnamemodify(target.path, ':h')) + end + end, + }, + { + label = 'cancel', + shortcut = 'q', + callback = function() + util.clear_extmarks(0, { lnum_idx, 0 }, { lnum_idx, -1 }, {}) + + for _, lhl in ipairs(cursor.line.highlights) do + util.add_highlight(0, lhl[1], lnum_idx, lhl[2], lhl[3]) + end + + self:render() + end, + }, + }, + }) +end + +function view:move() + local ctx = self:cursor() + local target_line = ctx.target_line + local targets = vim.list_extend( + { unpack(target_line.path) }, + { target_line.entry } + ) + local target_names = vim.tbl_map(function(part) + return part.name + end, targets) + + if ctx.target.path == self.root.path then + return + end + + local path_start = target_line.depth * 2 + 2 + target_line.icon_width + local lnum_idx = target_line.lnum - 1 + local target_idx = util.tbl_key(targets, ctx.target) + local clamped_names = { unpack(target_names, 1, target_idx - 1) } + local start_hl = path_start + #table.concat(clamped_names, '/') + + if target_idx > 1 then + start_hl = start_hl + 1 + end + + util.clear_extmarks(0, { lnum_idx, start_hl }, { lnum_idx, -1 }, {}) + util.add_highlight(0, 'CarbonPending', lnum_idx, start_hl, -1) + vim.cmd.redraw({ bang = true }) + vim.cmd.echohl('CarbonPending') + + local updated_path = string.gsub( + vim.fn.input({ + prompt = 'destination: ', + default = ctx.target.path, + cancelreturn = ctx.target.path, + }), + '/+$', + '' + ) + + vim.cmd.echohl('None') + vim.api.nvim_echo({ { ' ' } }, false, {}) + + if updated_path == ctx.target.path then + self:render() + elseif vim.loop.fs_stat(updated_path) then + self:render() + vim.api.nvim_echo({ + { 'Failed to move: ', 'CarbonDanger' }, + { vim.fn.fnamemodify(ctx.target.path, ':.'), 'CarbonIndicator' }, + { ' => ' }, + { vim.fn.fnamemodify(updated_path, ':.'), 'CarbonIndicator' }, + { ' (destination exists)', 'CarbonPending' }, + }, false, {}) + else + local directory = vim.fn.fnamemodify(updated_path, ':h') + local tmp_path = ctx.target.path + + if vim.startswith(updated_path, tmp_path) then + tmp_path = vim.fn.tempname() + + vim.fn.rename(ctx.target.path, tmp_path) + end + + vim.fn.mkdir(directory, 'p') + vim.fn.rename(tmp_path, updated_path) + view.resync(vim.fn.fnamemodify(ctx.target.path, ':h')) + end +end + +function view:parents(count) + local path = self.root.path + local parents = {} + + if path ~= '' then + for _ = count or vim.v.count1, 1, -1 do + path = vim.fn.fnamemodify(path, ':h') + parents[#parents + 1] = entry.new(path) + + if path == '/' then + break + end + end + end + + return parents +end + +function view:switch_to_existing_view(path) + local destination_view = view.find(path) + + if destination_view then + vim.api.nvim_win_set_buf(0, destination_view:buffer()) + + if settings.sync_pwd and self.root.path == vim.loop.cwd() then + vim.api.nvim_set_current_dir(destination_view.root.path) + end + + return true + end +end + +return view diff --git a/nvim/lua/carbon/watcher.lua b/nvim/lua/carbon/watcher.lua new file mode 100644 index 0000000..c46c73c --- /dev/null +++ b/nvim/lua/carbon/watcher.lua @@ -0,0 +1,87 @@ +local util = require('carbon.util') +local watcher = {} + +watcher.listeners = {} +watcher.events = {} + +function watcher.keep(callback) + for path in pairs(watcher.listeners) do + if not callback(path) then + watcher.release(path) + end + end +end + +function watcher.release(path) + if not path then + for listener_path in pairs(watcher.listeners) do + watcher.release(listener_path) + end + elseif watcher.listeners[path] then + watcher.listeners[path]:stop() + + watcher.listeners[path] = nil + end +end + +function watcher.register(path) + if not watcher.listeners[path] and not util.is_excluded(path) then + watcher.listeners[path] = vim.loop.new_fs_event() + + watcher.listeners[path]:start( + path, + {}, + vim.schedule_wrap(function(error, filename) + watcher.emit('carbon:synchronize', path, filename, error) + end) + ) + end +end + +function watcher.emit(event, ...) + for callback in pairs(watcher.events[event] or {}) do + callback(event, ...) + end + + for callback in pairs(watcher.events['*'] or {}) do + callback(event, ...) + end +end + +function watcher.on(event, callback) + if type(event) == 'table' then + for _, key in ipairs(event) do + watcher.on(key, callback) + end + elseif event then + watcher.events[event] = watcher.events[event] or {} + watcher.events[event][callback] = callback + end +end + +function watcher.off(event, callback) + if not event then + watcher.events = {} + elseif type(event) == 'table' then + for _, key in ipairs(event) do + watcher.off(key, callback) + end + elseif watcher.events[event] and callback then + watcher.events[event][callback] = nil + elseif watcher.events[event] then + watcher.events[event] = {} + else + watcher.events[event] = nil + end +end + +function watcher.has(event, callback) + return watcher.events[event] and watcher.events[event][callback] and true + or false +end + +function watcher.registered() + return vim.tbl_keys(watcher.listeners) +end + +return watcher