diff options
Diffstat (limited to 'yazi/plugins/fs-usage.yazi/main.lua')
| -rw-r--r-- | yazi/plugins/fs-usage.yazi/main.lua | 292 |
1 files changed, 292 insertions, 0 deletions
diff --git a/yazi/plugins/fs-usage.yazi/main.lua b/yazi/plugins/fs-usage.yazi/main.lua new file mode 100644 index 0000000..dbcdef2 --- /dev/null +++ b/yazi/plugins/fs-usage.yazi/main.lua @@ -0,0 +1,292 @@ +--- @since 25.5.31 + +local DEFAULT_OPTIONS = { + -- Can't reference Header.RIGHT etc. here (it hangs) so parent and align are strings + -- 2000 puts it to the right of the indicator, and leaves some room between + position = { parent = "Header", align = "RIGHT", order = 2000 }, + format = "both", + bar = true, + warning_threshold = 90, + style_label = { + fg = th.status.progress_label:fg(), + }, + style_normal = { + fg = th.status.progress_normal:fg(), + bg = th.status.progress_normal:bg(), + }, + style_warning = { + fg = th.status.progress_error:fg(), + bg = th.status.progress_error:bg(), + }, + padding = { open = "", close = "" }, +} + +---Deep copy and merge two tables, overwriting values from one table into another +---@param from Table to take values from +---@param to Table to merge into +local function merge(into, from) + -- Handle nil inputs + into = into or {} + from = from or {} + + local result = {} + + -- Deep copy 'into' first + for k, v in pairs(into) do + if type(v) == "table" then + result[k] = merge({}, v) + else + result[k] = v + end + end + + -- Merge + for k, v in pairs(from) do + if type(v) == "table" then + result[k] = merge(result[k], v) + else + result[k] = v + end + end + + return result +end + +---Merge label and bar styles into left/right styles for the bar +---@param style_label Label style +---@param style_bar Usage bar style +local function build_styles(style_label, style_bar) + local style_right = ui.Style():fg(style_label.fg or style_bar.fg):bg(style_bar.bg) -- Label bg is ignored + if style_label.bold then + style_right = style_right:bold() + end + if style_label.italic then + style_right = style_right:italic() + end + + -- Left style is the same as right, but with fg/bg reversed + -- (this is overridden by the label colour if set) + local style_left = ui.Style():patch(style_right):fg(style_label.fg or style_bar.bg):bg(style_bar.fg) -- Label bg is ignored + + return style_left, style_right +end + +---Parse source and usage from df output +---@param stdout string df output +local function process_df_output(stdout) + local source, usage = stdout:match(".*%s(%S+)%s+(%S+)") + + -- Follow symlinks in source + if string.sub(source, 1, 1) == "/" then + source = Command("readlink"):arg({ "--silent", "--canonicalize", "--no-newline", source }):output().stdout + end + + -- Assume usage is a valid percentage + -- Remove the percent and parse + usage = tonumber(string.sub(usage, 1, #usage - 1)) + + return source, usage +end + +---Format text based on options +---@param source Source +---@param usage Usage +---@param format Format +local function format_text(source, usage, format) + local text = "" + if format == "both" then + text = string.format("%s: %d%%", source, usage) + elseif format == "name" then + text = string.format("%s", source) + elseif format == "usage" then + text = string.format("%d%%", usage) + end + return text +end + +---Set new plugin state and redraw +local set_state = ya.sync(function(st, source, usage, text, bar_len) + st.source = source + st.usage = usage + st.text = text + st.bar_len = bar_len + + local render = ui.render or ya.render + render() +end) + +---Get plugin state needed by entry +local get_state = ya.sync(function(st) + return { + -- Persistent options + format = st.format, + bar = st.bar, + padding = st.padding, + + -- Variables + source = st.source, + usage = st.usage, + } +end) + +-- Called from init.lua +---@param st State +---@param opts Options +local function setup(st, opts) + opts = merge(DEFAULT_OPTIONS, opts) + + -- Allow unsetting some options + if opts.style_label.fg == "" then + opts.style_label.fg = nil + end + if opts.warning_threshold < 0 then + opts.warning_threshold = nil + end + + -- Translate opts.position.parent option into a component reference + if opts.position.parent == "Header" then + opts.position.parent = Header + elseif opts.position.parent == "Status" then + opts.position.parent = Status + else + -- Just set it to nil, it's gonna cause errors anyway + opts.position.parent = nil + end + + -- Set persistent options + local style_normal_left, style_normal_right = build_styles(opts.style_label, opts.style_normal) + local style_warning_left, style_warning_right = build_styles(opts.style_label, opts.style_warning) + st.style = { + normal = { + left = style_normal_left, + right = style_normal_right, + padding = { + left = ui.Style():fg(style_normal_left:bg()), + right = ui.Style():fg(style_normal_right:bg()), + }, + }, + warning = { + left = style_warning_left, + right = style_warning_right, + padding = { + left = ui.Style():fg(style_warning_left:bg()), + right = ui.Style():fg(style_warning_right:bg()), + }, + }, + } + st.format = opts.format + st.bar = opts.bar + st.warning_threshold = opts.warning_threshold + st.padding = opts.padding + + -- Add the component to the parent + opts.position.parent:children_add(function(self) + -- No point showing anything if usage is nil + if not st.usage then + return + end + + local style = (st.warning_threshold and st.usage >= st.warning_threshold) and st.style.warning + or st.style.normal + + -- Apply styles to components based on the bar length, and ad them to the bar + local output = {} + local bar_len = st.bar_len + local components = { + { text = st.padding.open, style = style.padding }, + { text = st.text, style = style }, + { text = st.padding.close, style = style.padding }, + } + for _, component in ipairs(components) do + -- bar_len_bytes should point to the last byte that should be coloured by the usage bar + local bar_len_bytes + if bar_len <= 0 then + -- 1-indexed, so effectively no bar showing + bar_len_bytes = 0 + elseif bar_len >= utf8.len(component.text) then + bar_len_bytes = #component.text + else + bar_len_bytes = utf8.offset(component.text, bar_len + 1) - 1 + end + + if bar_len_bytes > 0 then + table.insert(output, ui.Span(string.sub(component.text, 1, bar_len_bytes)):style(component.style.left)) + end + if bar_len_bytes < #component.text then + table.insert( + output, + ui.Span(string.sub(component.text, bar_len_bytes + 1)):style(component.style.right) + ) + end + + bar_len = bar_len - utf8.len(component.text) + end + + return ui.Line(output) + end, opts.position.order, opts.position.parent[opts.position.align]) + + ---Pass cwd to the plugin for df + local function callback() + ya.emit("plugin", { + st._id, + ya.quote(tostring(cx.active.current.cwd), true), + }) + end + + -- Subscribe to events + ps.sub("cd", callback) + ps.sub("tab", callback) + ps.sub("delete", callback) + -- These are the only relevant events that actually work + -- Note: df might not immediately reflect usage changes + -- when deleting files +end + +-- Called from ya.emit in the callback +---@param job Job +local function entry(_, job) + local cwd = job.args[1] + + -- Don't set cwd directly for Command() here, it hangs for dirs without read perms + -- cwd is fine as an argument to df though + local output = Command("df"):arg({ "--output=source,pcent", tostring(cwd) }):output() + + -- If df fails, hide the module + if not output.status.success then + set_state(nil, nil, nil, nil) + return + end + + local source, usage = process_df_output(output.stdout) + + -- If df read the filesystem but couldn't get a percentage, hide the module + -- if usage == nil then + if type(usage) ~= "number" then + set_state(nil, nil, nil, nil) + return + end + + -- Get the plugin state (async) early since we know it will be used + local st = get_state() + + -- If nothing has changed, don't bother updating + if source == st.source and usage == st.usage then + return + end + + local text = format_text(source, usage, st.format) + local bar_len = 0 -- Start with no bar by default + + -- Only calculate bar length if the bar will be shown + if st.bar then + local total_len = utf8.len(st.padding.open .. text .. st.padding.close) + + -- Using ceil so the bar is only empty at 0% + -- Using len - 1 so the bar isn't full until 100% + bar_len = usage < 100 and math.ceil((total_len - 1) / 100 * usage) or total_len + end + + set_state(source, usage, text, bar_len) +end + +return { setup = setup, entry = entry } |
