From 879d280617045d5a00d7a053e86d51c6c80970be Mon Sep 17 00:00:00 2001 From: Steven Arcangeli Date: Fri, 8 Sep 2023 18:55:45 -0700 Subject: [PATCH] feat: api to sort directory contents (#169) --- README.md | 8 ++++ doc/api.md | 10 +++++ doc/oil.txt | 24 ++++++++++- lua/oil/actions.lua | 26 +++++++++++ lua/oil/adapters/files.lua | 55 ++++++++++++++++-------- lua/oil/adapters/ssh.lua | 9 ++++ lua/oil/columns.lua | 56 ++++++++++++++++++++---- lua/oil/config.lua | 7 +++ lua/oil/init.lua | 6 +++ lua/oil/view.lua | 88 +++++++++++++++++++++++++++++--------- scripts/generate.py | 21 ++++++--- 11 files changed, 256 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 3c36d07..cc99bac 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ require("oil").setup({ ["_"] = "actions.open_cwd", ["`"] = "actions.cd", ["~"] = "actions.tcd", + ["gs"] = "actions.change_sort", ["g."] = "actions.toggle_hidden", }, -- Set to false to disable all of the above keymaps @@ -194,6 +195,12 @@ require("oil").setup({ is_always_hidden = function(name, bufnr) return false end, + sort = { + -- sort order can be "asc" or "desc" + -- see :help oil-columns to see which columns are sortable + { "type", "asc" }, + { "name", "asc" }, + }, }, -- Configuration for the floating window in oil.open_float float = { @@ -277,6 +284,7 @@ Note that at the moment the ssh adapter does not support Windows machines, and i - [get_cursor_entry()](doc/api.md#get_cursor_entry) - [discard_all_changes()](doc/api.md#discard_all_changes) - [set_columns(cols)](doc/api.md#set_columnscols) +- [set_sort(sort)](doc/api.md#set_sortsort) - [set_is_hidden_file(is_hidden_file)](doc/api.md#set_is_hidden_fileis_hidden_file) - [toggle_hidden()](doc/api.md#toggle_hidden) - [get_current_dir()](doc/api.md#get_current_dir) diff --git a/doc/api.md b/doc/api.md index f87c34e..3f49456 100644 --- a/doc/api.md +++ b/doc/api.md @@ -6,6 +6,7 @@ - [get_cursor_entry()](#get_cursor_entry) - [discard_all_changes()](#discard_all_changes) - [set_columns(cols)](#set_columnscols) +- [set_sort(sort)](#set_sortsort) - [set_is_hidden_file(is_hidden_file)](#set_is_hidden_fileis_hidden_file) - [toggle_hidden()](#toggle_hidden) - [get_current_dir()](#get_current_dir) @@ -52,6 +53,15 @@ Change the display columns for oil | ----- | ------------------ | ---- | | cols | `oil.ColumnSpec[]` | | +## set_sort(sort) + +`set_sort(sort)` \ +Change the sort order for oil + +| Param | Type | Desc | +| ----- | ---------- | ---- | +| sort | `string[]` | [] | + ## set_is_hidden_file(is_hidden_file) `set_is_hidden_file(is_hidden_file)` \ diff --git a/doc/oil.txt b/doc/oil.txt index 9c3f8c8..402deb3 100644 --- a/doc/oil.txt +++ b/doc/oil.txt @@ -12,7 +12,7 @@ CONTENTS *oil-content -------------------------------------------------------------------------------- OPTIONS *oil-options* -> +>lua require("oil").setup({ -- Oil will take over directory buffers (e.g. `vim .` or `:e src/`) -- Set to false if you still want to use netrw. @@ -70,6 +70,7 @@ OPTIONS *oil-option ["_"] = "actions.open_cwd", ["`"] = "actions.cd", ["~"] = "actions.tcd", + ["gs"] = "actions.change_sort", ["g."] = "actions.toggle_hidden", }, -- Set to false to disable all of the above keymaps @@ -85,6 +86,12 @@ OPTIONS *oil-option is_always_hidden = function(name, bufnr) return false end, + sort = { + -- sort order can be "asc" or "desc" + -- see :help oil-columns to see which columns are sortable + { "type", "asc" }, + { "name", "asc" }, + }, }, -- Configuration for the floating window in oil.open_float float = { @@ -166,6 +173,12 @@ set_columns({cols}) *oil.set_column Parameters: {cols} `oil.ColumnSpec[]` +set_sort({sort}) *oil.set_sort* + Change the sort order for oil + + Parameters: + {sort} `string[]` [] + set_is_hidden_file({is_hidden_file}) *oil.set_is_hidden_file* Change how oil determines if the file is hidden @@ -244,6 +257,7 @@ or as a table to pass parameters (e.g. `{"size", highlight = "Special"}`) type *column-type* Adapters: * + Sortable: this column can be used in view_props.sort The type of the entry (file, directory, link, etc) Parameters: @@ -266,6 +280,7 @@ icon *column-ico size *column-size* Adapters: files, ssh + Sortable: this column can be used in view_props.sort The size of the file Parameters: @@ -283,6 +298,7 @@ permissions *column-permission ctime *column-ctime* Adapters: files + Sortable: this column can be used in view_props.sort Change timestamp of the file Parameters: @@ -292,6 +308,7 @@ ctime *column-ctim mtime *column-mtime* Adapters: files + Sortable: this column can be used in view_props.sort Last modified time of the file Parameters: @@ -301,6 +318,7 @@ mtime *column-mtim atime *column-atime* Adapters: files + Sortable: this column can be used in view_props.sort Last access time of the file Parameters: @@ -310,6 +328,7 @@ atime *column-atim birthtime *column-birthtime* Adapters: files + Sortable: this column can be used in view_props.sort The time the file was created Parameters: @@ -325,6 +344,9 @@ These are actions that can be used in the `keymaps` section of config options. cd *actions.cd* :cd to the current oil directory +change_sort *actions.change_sort* + Change the sort order + close *actions.close* Close oil and restore original buffer diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua index e5468c8..7596a18 100644 --- a/lua/oil/actions.lua +++ b/lua/oil/actions.lua @@ -237,6 +237,32 @@ M.open_cmdline_dir = { end, } +M.change_sort = { + desc = "Change the sort order", + callback = function() + local sort_cols = { "name", "size", "atime", "mtime", "ctime", "birthtime" } + vim.ui.select(sort_cols, { prompt = "Sort by", kind = "oil_sort_col" }, function(col) + if not col then + return + end + vim.ui.select( + { "ascending", "descending" }, + { prompt = "Sort order", kind = "oil_sort_order" }, + function(order) + if not order then + return + end + order = order == "ascending" and "asc" or "desc" + oil.set_sort({ + { "type", "asc" }, + { col, order }, + }) + end + ) + end) + end, +} + ---List actions for documentation generation ---@private M._get_actions = function() diff --git a/lua/oil/adapters/files.lua b/lua/oil/adapters/files.lua index cba0228..29641f9 100644 --- a/lua/oil/adapters/files.lua +++ b/lua/oil/adapters/files.lua @@ -13,22 +13,20 @@ local FIELD_NAME = constants.FIELD_NAME local FIELD_META = constants.FIELD_META local function read_link_data(path, cb) - uv.fs_readlink( - path, - vim.schedule_wrap(function(link_err, link) - if link_err then - cb(link_err) - else - local stat_path = link - if not fs.is_absolute(link) then - stat_path = fs.join(vim.fn.fnamemodify(path, ":h"), link) - end - uv.fs_stat(stat_path, function(stat_err, stat) - cb(nil, link, stat) - end) + uv.fs_readlink(path, function(link_err, link) + if link_err then + cb(link_err) + else + assert(link) + local stat_path = link + if not fs.is_absolute(link) then + stat_path = fs.join(vim.fn.fnamemodify(path, ":h"), link) end - end) - ) + uv.fs_stat(stat_path, function(stat_err, stat) + cb(nil, link, stat) + end) + end + end) end ---@param path string @@ -60,7 +58,7 @@ file_columns.size = { local meta = entry[FIELD_META] local stat = meta.stat if not stat then - return "" + return columns.EMPTY end if stat.size >= 1e9 then return string.format("%.1fG", stat.size / 1e9) @@ -73,6 +71,16 @@ file_columns.size = { end end, + get_sort_value = function(entry) + local meta = entry[FIELD_META] + local stat = meta.stat + if stat then + return stat.size + else + return 0 + end + end, + parse = function(line, conf) return line:match("^(%d+%S*)%s+(.*)$") end, @@ -87,7 +95,7 @@ if not fs.is_windows then local meta = entry[FIELD_META] local stat = meta.stat if not stat then - return "" + return columns.EMPTY end return permissions.mode_to_str(stat.mode) end, @@ -145,6 +153,9 @@ for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do render = function(entry, conf) local meta = entry[FIELD_META] local stat = meta.stat + if not stat then + return columns.EMPTY + end local fmt = conf and conf.format local ret if fmt then @@ -170,6 +181,16 @@ for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do end return line:match("^(" .. pattern .. ")%s+(.+)$") end, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + local stat = meta.stat + if stat then + return stat[time_key].sec + else + return 0 + end + end, } end diff --git a/lua/oil/adapters/ssh.lua b/lua/oil/adapters/ssh.lua index 1491f23..d0584f8 100644 --- a/lua/oil/adapters/ssh.lua +++ b/lua/oil/adapters/ssh.lua @@ -157,6 +157,15 @@ ssh_columns.size = { parse = function(line, conf) return line:match("^(%d+%S*)%s+(.*)$") end, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + if meta.size then + return meta.size + else + return 0 + end + end, } ---@param name string diff --git a/lua/oil/columns.lua b/lua/oil/columns.lua index 4c1031e..c3f9284 100644 --- a/lua/oil/columns.lua +++ b/lua/oil/columns.lua @@ -15,10 +15,11 @@ local all_columns = {} ---@class (exact) oil.ColumnDefinition ---@field render fun(entry: oil.InternalEntry, conf: nil|table): nil|oil.TextChunk ---@field parse fun(line: string, conf: nil|table): nil|string, nil|string ----@field meta_fields nil|table +---@field meta_fields? table ---@field compare? fun(entry: oil.InternalEntry, parsed_value: any): boolean ---@field render_action? fun(action: oil.ChangeAction): string ---@field perform_action? fun(action: oil.ChangeAction, callback: fun(err: nil|string)) +---@field get_sort_value? fun(entry: oil.InternalEntry): number|string ---@param name string ---@param column oil.ColumnDefinition @@ -29,7 +30,7 @@ end ---@param adapter oil.Adapter ---@param defn oil.ColumnSpec ---@return nil|oil.ColumnDefinition -local function get_column(adapter, defn) +M.get_column = function(adapter, defn) local name = util.split_config(defn) return all_columns[name] or adapter.get_column(name) end @@ -46,7 +47,7 @@ M.get_supported_columns = function(adapter_or_scheme) assert(adapter) local ret = {} for _, def in ipairs(config.columns) do - if get_column(adapter, def) then + if M.get_column(adapter, def) then table.insert(ret, def) end end @@ -61,7 +62,7 @@ M.get_metadata_fetcher = function(adapter, column_defs) local num_keys = 0 for _, def in ipairs(column_defs) do local name = util.split_config(def) - local column = get_column(adapter, name) + local column = M.get_column(adapter, name) if column and column.meta_fields then for k, v in pairs(column.meta_fields) do if not keyfetches[k] then @@ -95,13 +96,15 @@ end local EMPTY = { "-", "Comment" } +M.EMPTY = EMPTY + ---@param adapter oil.Adapter ---@param col_def oil.ColumnSpec ---@param entry oil.InternalEntry ---@return oil.TextChunk M.render_col = function(adapter, col_def, entry) local name, conf = util.split_config(col_def) - local column = get_column(adapter, name) + local column = M.get_column(adapter, name) if not column then -- This shouldn't be possible because supports_col should return false return EMPTY @@ -150,7 +153,7 @@ M.parse_col = function(adapter, line, col_def) if vim.startswith(line, "- ") then return nil, line:sub(3) end - local column = get_column(adapter, name) + local column = M.get_column(adapter, name) if column then return column.parse(line, conf) end @@ -162,7 +165,7 @@ end ---@param parsed_value any ---@return boolean M.compare = function(adapter, col_name, entry, parsed_value) - local column = get_column(adapter, col_name) + local column = M.get_column(adapter, col_name) if column and column.compare then return column.compare(entry, parsed_value) else @@ -174,7 +177,7 @@ end ---@param action oil.ChangeAction ---@return string M.render_change_action = function(adapter, action) - local column = get_column(adapter, action.column) + local column = M.get_column(adapter, action.column) if not column then error(string.format("Received change action for nonexistant column %s", action.column)) end @@ -189,7 +192,7 @@ end ---@param action oil.ChangeAction ---@param callback fun(err: nil|string) M.perform_change_action = function(adapter, action, callback) - local column = get_column(adapter, action.column) + local column = M.get_column(adapter, action.column) if not column then return callback( string.format("Received change action for nonexistant column %s", action.column) @@ -236,6 +239,19 @@ local default_type_icons = { directory = "dir", socket = "sock", } +---@param entry oil.InternalEntry +---@return boolean +local function is_entry_directory(entry) + local type = entry[FIELD_TYPE] + if type == "directory" then + return true + elseif type == "link" then + local meta = entry[FIELD_META] + return meta and meta.link_stat and meta.link_stat.type == "directory" + else + return false + end +end M.register("type", { render = function(entry, conf) local entry_type = entry[FIELD_TYPE] @@ -249,6 +265,28 @@ M.register("type", { parse = function(line, conf) return line:match("^(%S+)%s+(.*)$") end, + + get_sort_value = function(entry) + if is_entry_directory(entry) then + return 1 + else + return 2 + end + end, +}) + +M.register("name", { + render = function(entry, conf) + error("Do not use the name column. It is for sorting only") + end, + + parse = function(line, conf) + error("Do not use the name column. It is for sorting only") + end, + + get_sort_value = function(entry) + return entry[FIELD_NAME] + end, }) return M diff --git a/lua/oil/config.lua b/lua/oil/config.lua index f66c2a9..acf4ddc 100644 --- a/lua/oil/config.lua +++ b/lua/oil/config.lua @@ -55,6 +55,7 @@ local default_config = { ["_"] = "actions.open_cwd", ["`"] = "actions.cd", ["~"] = "actions.tcd", + ["gs"] = "actions.change_sort", ["g."] = "actions.toggle_hidden", }, -- Set to false to disable all of the above keymaps @@ -70,6 +71,12 @@ local default_config = { is_always_hidden = function(name, bufnr) return false end, + sort = { + -- sort order can be "asc" or "desc" + -- see :help oil-columns to see which columns are sortable + { "type", "asc" }, + { "name", "asc" }, + }, }, -- Configuration for the floating window in oil.open_float float = { diff --git a/lua/oil/init.lua b/lua/oil/init.lua index 99ea008..68a3474 100644 --- a/lua/oil/init.lua +++ b/lua/oil/init.lua @@ -142,6 +142,12 @@ M.set_columns = function(cols) require("oil.view").set_columns(cols) end +---Change the sort order for oil +---@param sort string[][] +M.set_sort = function(sort) + require("oil.view").set_sort(sort) +end + ---Change how oil determines if the file is hidden ---@param is_hidden_file fun(filename: string, bufnr: nil|integer): boolean Return true if the file/dir should be hidden M.set_is_hidden_file = function(is_hidden_file) diff --git a/lua/oil/view.lua b/lua/oil/view.lua index 191262f..047996b 100644 --- a/lua/oil/view.lua +++ b/lua/oil/view.lua @@ -100,6 +100,17 @@ M.set_columns = function(cols) end end +M.set_sort = function(new_sort) + local any_modified = are_any_modified() + if any_modified then + vim.notify("Cannot change sorting when you have unsaved changes", vim.log.levels.WARN) + else + config.view_options.sort = new_sort + -- TODO only refetch if we don't have all the necessary data for the columns + M.rerender_all_oil_buffers({ refetch = true }) + end +end + -- List of bufnrs local session = {} @@ -351,17 +362,46 @@ M.initialize = function(bufnr) keymap_util.set_keymaps("", config.keymaps, bufnr) end ----@param entry oil.InternalEntry ----@return boolean -local function is_entry_directory(entry) - local type = entry[FIELD_TYPE] - if type == "directory" then - return true - elseif type == "link" then - local meta = entry[FIELD_META] - return meta and meta.link_stat and meta.link_stat.type == "directory" - else - return false +---@param adapter oil.Adapter +---@return fun(a: oil.InternalEntry, b: oil.InternalEntry): boolean +local function get_sort_function(adapter) + local idx_funs = {} + for _, sort_pair in ipairs(config.view_options.sort) do + local col_name, order = unpack(sort_pair) + if order ~= "asc" and order ~= "desc" then + vim.notify_once( + string.format( + "Column '%s' has invalid sort order '%s'. Should be either 'asc' or 'desc'", + col_name, + order + ), + vim.log.levels.WARN + ) + end + local col = columns.get_column(adapter, col_name) + if col and col.get_sort_value then + table.insert(idx_funs, { col.get_sort_value, order }) + else + vim.notify_once( + string.format("Column '%s' does not support sorting", col_name), + vim.log.levels.WARN + ) + end + end + return function(a, b) + for _, sort_fn in ipairs(idx_funs) do + local get_sort_value, order = unpack(sort_fn) + local a_val = get_sort_value(a) + local b_val = get_sort_value(b) + if a_val ~= b_val then + if order == "desc" then + return a_val > b_val + else + return a_val < b_val + end + end + end + return a[FIELD_NAME] < b[FIELD_NAME] end end @@ -390,14 +430,7 @@ local function render_buffer(bufnr, opts) local entries = cache.list_url(bufname) local entry_list = vim.tbl_values(entries) - table.sort(entry_list, function(a, b) - local a_isdir = is_entry_directory(a) - local b_isdir = is_entry_directory(b) - if a_isdir ~= b_isdir then - return a_isdir - end - return a[FIELD_NAME] < b[FIELD_NAME] - end) + table.sort(entry_list, get_sort_function(adapter)) local jump_idx if opts.jump_first then @@ -512,6 +545,21 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter) return cols end +---Get the column names that are used for view and sort +---@return string[] +local function get_used_columns() + local cols = {} + for _, def in ipairs(config.columns) do + local name = util.split_config(def) + table.insert(cols, name) + end + for _, sort_pair in ipairs(config.view_options.sort) do + local name = sort_pair[1] + table.insert(cols, name) + end + return cols +end + ---@param bufnr integer ---@param opts nil|table --- preserve_undo nil|boolean @@ -579,7 +627,7 @@ M.render_buffer_async = function(bufnr, opts, callback) end cache.begin_update_url(bufname) - adapter.list(bufname, config.columns, function(err, entries, fetch_more) + adapter.list(bufname, get_used_columns(), function(err, entries, fetch_more) loading.set_loading(bufnr, false) if err then cache.end_update_url(bufname) diff --git a/scripts/generate.py b/scripts/generate.py index 1508168..2f8e263 100755 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -88,6 +88,7 @@ class ColumnDef: name: str adapters: str editable: bool + sortable: bool summary: str params: List["LuaParam"] = field(default_factory=list) @@ -107,6 +108,7 @@ COL_DEFS = [ "type", "*", False, + True, "The type of the entry (file, directory, link, etc)", HL + [LuaParam("icons", "table", "Mapping of entry type to icon")], @@ -115,6 +117,7 @@ COL_DEFS = [ "icon", "*", False, + False, "An icon for the entry's type (requires nvim-web-devicons)", HL + [ @@ -123,17 +126,17 @@ COL_DEFS = [ LuaParam("add_padding", "boolean", "Set to false to remove the extra whitespace after the icon"), ], ), - ColumnDef("size", "files, ssh", False, "The size of the file", HL + []), + ColumnDef("size", "files, ssh", False, True, "The size of the file", HL + []), ColumnDef( - "permissions", "files, ssh", True, "Access permissions of the file", HL + [] + "permissions", "files, ssh", True, False, "Access permissions of the file", HL + [] ), - ColumnDef("ctime", "files", False, "Change timestamp of the file", HL + TIME + []), + ColumnDef("ctime", "files", False, True, "Change timestamp of the file", HL + TIME + []), ColumnDef( - "mtime", "files", False, "Last modified time of the file", HL + TIME + [] + "mtime", "files", False, True, "Last modified time of the file", HL + TIME + [] ), - ColumnDef("atime", "files", False, "Last access time of the file", HL + TIME + []), + ColumnDef("atime", "files", False, True, "Last access time of the file", HL + TIME + []), ColumnDef( - "birthtime", "files", False, "The time the file was created", HL + TIME + [] + "birthtime", "files", False, True, "The time the file was created", HL + TIME + [] ), ] @@ -142,7 +145,7 @@ def get_options_vimdoc() -> "VimdocSection": section = VimdocSection("options", "oil-options") config_file = os.path.join(ROOT, "lua", "oil", "config.lua") opt_lines = read_section(config_file, r"^local default_config =", r"^}$") - lines = ["\n", ">\n", ' require("oil").setup({\n'] + lines = ["\n", ">lua\n", ' require("oil").setup({\n'] lines.extend(indent(opt_lines, 4)) lines.extend([" })\n", "<\n"]) section.body = lines @@ -193,6 +196,10 @@ def get_columns_vimdoc() -> "VimdocSection": for col in COL_DEFS: section.body.append(leftright(col.name, f"*column-{col.name}*")) section.body.extend(wrap(f"Adapters: {col.adapters}", 4)) + if col.sortable: + section.body.extend( + wrap(f"Sortable: this column can be used in view_props.sort", 4) + ) if col.editable: section.body.extend(wrap(f"Editable: this column is read/write", 4)) section.body.extend(wrap(col.summary, 4))