summaryrefslogtreecommitdiff
path: root/yazi/plugins/fs-usage.yazi/main.lua
blob: dbcdef2a2befa04dead572b24c0e9b357fd027c5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
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 }