summaryrefslogtreecommitdiff
path: root/yazi/plugins/fs-usage.yazi/main.lua
diff options
context:
space:
mode:
Diffstat (limited to 'yazi/plugins/fs-usage.yazi/main.lua')
-rw-r--r--yazi/plugins/fs-usage.yazi/main.lua292
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 }