1 /*
2  * Copyright (C) 2020, 2021, 2022  Vladimir Panteleev <btdu@cy.md>
3  *
4  * This program is free software; you can redistribute it and/or
5  * modify it under the terms of the GNU General Public
6  * License v2 as published by the Free Software Foundation.
7  *
8  * This program is distributed in the hope that it will be useful,
9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
11  * General Public License for more details.
12  *
13  * You should have received a copy of the GNU General Public
14  * License along with this program; if not, write to the
15  * Free Software Foundation, Inc., 59 Temple Place - Suite 330,
16  * Boston, MA 021110-1307, USA.
17  */
18 
19 /// ncurses interface for browsing results
20 module btdu.browser;
21 
22 import core.stdc.config;
23 import core.stdc.locale;
24 import core.stdc.stddef : wchar_t;
25 import core.thread : Thread;
26 import core.time;
27 
28 import std.algorithm.comparison;
29 import std.algorithm.iteration;
30 import std.algorithm.mutation;
31 import std.algorithm.searching;
32 import std.algorithm.sorting;
33 import std.conv;
34 import std.encoding : sanitize;
35 import std.exception : errnoEnforce;
36 import std.format;
37 import std.range;
38 import std.string;
39 
40 import deimos.ncurses;
41 
42 import ae.sys.file : listDir;
43 import ae.utils.appender;
44 import ae.utils.meta;
45 import ae.utils.text;
46 import ae.utils.time : stdDur, stdTime;
47 
48 import btdu.common;
49 import btdu.state;
50 import btdu.paths;
51 
52 struct Browser
53 {
54 	BrowserPath* currentPath;
55 	sizediff_t top; // Scroll offset (row number, in the content, corresponding to the topmost displayed line)
56 	sizediff_t contentAreaHeight; // Number of rows where scrolling content is displayed
57 	BrowserPath* selection;
58 	BrowserPath*[] items;
59 	string[] textLines;
60 	bool done;
61 
62 	enum Mode
63 	{
64 		browser,
65 		info,
66 		help,
67 		deleteConfirm,
68 		deleteProgress,
69 		deleteError,
70 	}
71 	Mode mode;
72 
73 	enum SortMode
74 	{
75 		name,
76 		size,
77 	}
78 	SortMode sortMode;
79 	bool reverseSort, dirsFirst;
80 
81 	enum RatioDisplayMode
82 	{
83 		none,
84 		graph,
85 		percentage,
86 		both,
87 	}
88 	RatioDisplayMode ratioDisplayMode = RatioDisplayMode.graph;
89 
90 	Thread deleteThread;
91 	string deleteCurrent, deleteError;
92 	bool deleteStop;
93 
94 	void start()
95 	{
96 		setlocale(LC_CTYPE, "");
97 		initscr();
98 
99 		timeout(0); // Use non-blocking read
100 		cbreak(); // Disable line buffering
101 		noecho(); // Disable keyboard echo
102 		keypad(stdscr, true); // Enable arrow keys
103 		curs_set(0); // Hide cursor
104 
105 		currentPath = &browserRoot;
106 	}
107 
108 	~this()
109 	{
110 		endwin();
111 	}
112 
113 	@property bool needRefresh()
114 	{
115 		return mode == Mode.deleteProgress;
116 	}
117 
118 	@disable this(this);
119 
120 	string message; MonoTime showMessageUntil;
121 	void showMessage(string s)
122 	{
123 		message = s;
124 		showMessageUntil = MonoTime.currTime() + (100.msecs * s.length);
125 	}
126 
127 	private static FastAppender!char buf; // Reusable buffer
128 
129 	// Returns full path as string, or null.
130 	private static char[] getFullPath(BrowserPath* path)
131 	{
132 		buf.clear();
133 		buf.put(fsPath);
134 		bool recurse(BrowserPath *path)
135 		{
136 			string name = path.name[];
137 			if (name.skipOverNul())
138 				switch (name)
139 				{
140 					case "DATA":
141 					case "UNREACHABLE":
142 						return true;
143 					default:
144 						return false;
145 				}
146 			if (path.parent)
147 				if (!recurse(path.parent))
148 					return false;
149 			buf.put('/');
150 			buf.put(name);
151 			return true;
152 		}
153 		if (recurse(path))
154 			return buf.get();
155 		else
156 			return null;
157 	}
158 
159 	void update()
160 	{
161 		int h, w;
162 		getmaxyx(stdscr, h, w); h++; w++;
163 
164 		if (mode == Mode.deleteProgress && !deleteThread.isRunning())
165 		{
166 			try
167 			{
168 				deleteThread.join();
169 
170 				// Success:
171 				showMessage("Deleted " ~ selection.humanName ~ ".");
172 				mode = Mode.browser;
173 				selection.remove();
174 				selection = null;
175 			}
176 			catch (Exception e)
177 			{
178 				// Failure:
179 				deleteError = e.msg;
180 				mode = Mode.deleteError;
181 			}
182 			deleteThread = null;
183 		}
184 
185 		items = null;
186 		for (auto child = currentPath.firstChild; child; child = child.nextSibling)
187 			items ~= child;
188 
189 		final switch (sortMode)
190 		{
191 			case SortMode.name:
192 				items.sort!((a, b) => a.name[] < b.name[]);
193 				break;
194 			case SortMode.size:
195 				items.multiSort!(
196 					(a, b) => a.data[SampleType.represented].samples > b.data[SampleType.represented].samples,
197 					(a, b) => a.name[] < b.name[],
198 				);
199 				break;
200 		}
201 		if (reverseSort)
202 			items.reverse();
203 		if (dirsFirst)
204 			items.sort!(
205 				(a, b) => !!a.firstChild > !!b.firstChild,
206 				SwapStrategy.stable,
207 			);
208 
209 		if (!selection && items.length)
210 			selection = items[0];
211 
212 		if (!items.length && mode == Mode.browser && currentPath !is &browserRoot)
213 			mode = Mode.info;
214 
215 		auto totalSamples = browserRoot.data[SampleType.represented].samples;
216 
217 		// Build info
218 		final switch (mode)
219 		{
220 			case Mode.browser:
221 			case Mode.info:
222 			case Mode.deleteConfirm:
223 			case Mode.deleteProgress:
224 			case Mode.deleteError:
225 			{
226 				string[][] info;
227 
228 				auto fullPath = getFullPath(currentPath);
229 
230 				string[] showSampleType(SampleType type, string name, bool showError)
231 				{
232 					return [
233 						"- " ~ name ~ ": " ~ (totalSamples
234 							? format!"~%s (%d sample%s)%s"(
235 								humanSize(currentPath.data[type].samples * real(totalSize) / totalSamples),
236 								currentPath.data[type].samples,
237 								currentPath.data[type].samples == 1 ? "" : "s",
238 								showError ? ", ±" ~ humanSize(estimateError(totalSamples, currentPath.data[type].samples) * totalSize) : "",
239 							)
240 							: "-"),
241 
242 						// "  - Average query duration: " ~ (currentPath.data[type].samples
243 						// 	? stdDur(currentPath.data[type].duration / currentPath.data[type].samples).toString()
244 						// 	: "-"),
245 
246 						(expert ? "  " : "") ~ "- Logical offsets: " ~ (currentPath.data[type].samples
247 							? format!"%s%(%d, %)"(
248 								currentPath.data[type].samples > currentPath.data[type].logicalOffsets.length ? "..., " : "",
249 								currentPath.data[type].logicalOffsets[].filter!(o => o != ulong.max),
250 							)
251 							: "-"),
252 					];
253 				}
254 
255 				info ~= chain(
256 					["--- Details: "],
257 
258 					fullPath ? ["- Full path: " ~ cast(string)fullPath] : [],
259 
260 					(){
261 						string[] result;
262 						if (currentPath.parent && currentPath.parent.parent && currentPath.parent.parent.name[] == "\0ERROR")
263 						{
264 							auto errno = currentPath.name[] in errnoLookup;
265 							if (errno)
266 							{
267 								result ~= "- Error code: " ~ text(*errno);
268 								auto description = getErrno(*errno).description;
269 								if (description)
270 									result ~= "- Error message: " ~ description;
271 							}
272 						}
273 						return result;
274 					}(),
275 
276 					["- Average query duration: " ~ (currentPath.data[SampleType.represented].samples
277 							? stdDur(currentPath.data[SampleType.represented].duration / currentPath.data[SampleType.represented].samples).toDecimalString()
278 							: "-")],
279 				).array;
280 
281 				if (expert)
282 				{
283 					info ~= showSampleType(SampleType.represented, "Represented size", true);
284 					info ~= ["- Distributed size: " ~ (totalSamples
285 						? format!"~%s (%1.3f sample%s)"(
286 							humanSize(currentPath.distributedSamples * real(totalSize) / totalSamples),
287 							currentPath.distributedSamples,
288 							currentPath.distributedSamples == 1 ? "" : "s",
289 						)
290 						: "-")];
291 
292 					info ~= showSampleType(SampleType.exclusive, "Exclusive size", true);
293 					info ~= showSampleType(SampleType.shared_, "Shared size", false);
294 				}
295 				else
296 				{
297 					info[$-1] ~= showSampleType(SampleType.represented, "Represented size", true);
298 				}
299 
300 				{
301 					string explanation = {
302 						if (currentPath is &browserRoot)
303 							return
304 								"Welcome to btdu. You are in the hierarchy root; " ~
305 								"results will be arranged according to their block group and profile, and then by path." ~
306 								"\n\n" ~
307 								"Use the arrow keys to navigate, press ? for help.";
308 
309 						string name = currentPath.name[];
310 						if (name.skipOverNul())
311 						{
312 							switch (name)
313 							{
314 								case "DATA":
315 									return
316 										"This node holds samples from chunks in the DATA block group, " ~
317 										"which mostly contains file data.";
318 								case "METADATA":
319 									return
320 										"This node holds samples from chunks in the METADATA block group, " ~
321 										"which contains btrfs internal metadata arranged in b-trees." ~
322 										"\n\n" ~
323 										"The contents of small files may be stored here, in line with their metadata." ~
324 										"\n\n" ~
325 										"The contents of METADATA chunks is opaque to btdu, so this node does not have children.";
326 								case "SYSTEM":
327 									return
328 										"This node holds samples from chunks in the SYSTEM block group " ~
329 										"which contains some core btrfs information, such as how to map physical device space to linear logical space or vice-versa." ~
330 										"\n\n" ~
331 										"The contents of SYSTEM chunks is opaque to btdu, so this node does not have children.";
332 								case "SINGLE":
333 								case "RAID0":
334 								case "RAID1":
335 								case "DUP":
336 								case "RAID10":
337 								case "RAID5":
338 								case "RAID6":
339 								case "RAID1C3":
340 								case "RAID1C4":
341 									return
342 										"This node holds samples from chunks in the " ~ name ~ " profile.";
343 								case "ERROR":
344 									return
345 										"This node represents sample points for which btdu encountered an error when attempting to query them." ~
346 										"\n\n" ~
347 										"Children of this node indicate the encountered error, and may have a more detailed explanation attached.";
348 								case "ROOT_TREE":
349 									return
350 										"This node holds samples with inodes contained in the BTRFS_ROOT_TREE_OBJECTID object." ~
351 										"\n\n" ~
352 										"These samples are not resolvable to paths, and most likely indicate some kind of metadata. " ~
353 										"(If you know, please tell me!)";
354 								case "NO_INODE":
355 									return
356 										"This node represents sample points for which btrfs successfully completed our request " ~
357 										"to look up inodes at the given logical offset, but did not actually return any inodes." ~
358 										"\n\n" ~
359 										"One possible cause is data which was deleted recently.";
360 								case "NO_PATH":
361 									return
362 										"This node represents sample points for which btrfs successfully completed our request " ~
363 										"to look up filesystem paths for the given inode, but did not actually return any paths.";
364 								case "UNREACHABLE":
365 									return
366 										"This node represents sample points in extents which are not used by any files.\n" ~
367 										"Despite not being directly used, these blocks are kept because another part of the extent they belong to is actually used by files." ~
368 										"\n\n" ~
369 										"This can happen if a large file is written in one go, and then later one block is overwritten - " ~
370 										"btrfs may keep the old extent which still contains the old copy of the overwritten block." ~
371 										"\n\n" ~
372 										"Children of this node indicate the path of files using the extent containing the unreachable samples. " ~
373 										"Defragmentation of these files may reduce the amount of such unreachable blocks.";
374 								default:
375 									if (name.skipOver("TREE_"))
376 										return
377 											"This node holds samples with inodes contained in the tree #" ~ name ~ ", " ~
378 											"but btdu failed to resolve this tree number to an absolute path." ~
379 											"\n\n" ~
380 											"One possible cause is subvolumes which were deleted recently." ~
381 											"\n\n" ~
382 											"Another possible cause is \"ghost subvolumes\", a form of corruption which causes some orphan subvolumes to not get cleaned up.";
383 									debug assert(false, "Unknown special node: " ~ name);
384 							}
385 						}
386 
387 						if (currentPath.parent && currentPath.parent.name[] == "\0ERROR")
388 						{
389 							switch (name)
390 							{
391 								case "Unresolvable root":
392 									return
393 										"btdu failed to resolve this tree number to an absolute path.";
394 								case "logical ino":
395 									return
396 										"An error occurred while trying to look up which inodes use a particular logical offset." ~
397 										"\n\n" ~
398 										"Children of this node indicate the encountered error code, and may have a more detailed explanation attached.";
399 								case "open":
400 									return
401 										"btdu failed to open the filesystem root containing an inode." ~
402 										"\n\n" ~
403 										"Children of this node indicate the encountered error code, and may have a more detailed explanation attached.";
404 								default:
405 							}
406 						}
407 
408 						if (currentPath.parent && currentPath.parent.parent && currentPath.parent.parent.name[] == "\0ERROR")
409 						{
410 							switch (currentPath.parent.name[])
411 							{
412 								case "logical ino":
413 									switch (name)
414 									{
415 										case "ENOENT":
416 											return
417 												"Not an actual error - btrfs simply reports that there is nothing at the random sample location that btdu picked." ~
418 												"\n\n" ~
419 												"This most likely represents allocated but unused space, " ~
420 												"which could be reduced by running a balance on the DATA block group." ~
421 												"\n\n" ~
422 												"Note that even though this node is categorized as an error in btdu's hierarchy, it does not actually indicate a problem with the filesystem.";
423 										case "ENOTTY":
424 											return
425 												"An ENOTTY (\"Inappropriate ioctl for device\") error means that btdu issued an ioctl which the kernel btrfs code does not understand." ~
426 												"\n\n" ~
427 												"The most likely cause is that you are running an old kernel version. " ~
428 												"If you update your kernel, btdu might be able to show more information instead of this error.";
429 										default:
430 									}
431 									break;
432 								case "open":
433 									switch (name)
434 									{
435 										case "ENOENT":
436 											return
437 												"btdu failed to open the filesystem root containing an inode." ~
438 												"\n\n" ~
439 												"The most likely reason for this is that you didn't specify the path to the volume root when starting btdu, " ~
440 												"and instead specified the path to a subvolume or subdirectory." ~
441 												"\n\n" ~
442 												"You can descend into this node to see the path that btdu failed to open.";
443 										default:
444 									}
445 									break;
446 								default:
447 							}
448 						}
449 
450 						return null;
451 					}();
452 
453 					if (explanation)
454 						info ~= ["--- Explanation: "] ~ explanation.verbatimWrap(w).replace("\n ", "\n").strip().split("\n");
455 				}
456 
457 				bool showSeenAs;
458 				if (currentPath.seenAs.empty)
459 					showSeenAs = false;
460 				else
461 				if (fullPath is null && currentPath.seenAs.length == 1)
462 					showSeenAs = false; // Not a real file
463 				else
464 					showSeenAs = true;
465 
466 				if (showSeenAs)
467 				{
468 					auto representedSamples = currentPath.data[SampleType.represented].samples;
469 					info ~= ["--- Shares data with: "] ~
470 						currentPath.seenAs
471 						.byKeyValue
472 						.array
473 						.sort!((ref a, ref b) => a.key < b.key)
474 						.map!(pair => format("- %s (%d%%)", pair.key, pair.value * 100 / representedSamples))
475 						.array;
476 				}
477 
478 				textLines = info.join([""]);
479 				if (mode == Mode.info)
480 				{
481 					if (!textLines.length)
482 					{
483 						if (items.length)
484 							textLines = ["  (no info for this node - press i or q to exit)"];
485 						else
486 							textLines = ["  (empty node)"];
487 					}
488 					textLines = [""] ~ textLines;
489 				}
490 				break;
491 			}
492 			case Mode.help:
493 				textLines = help.dup;
494 				break;
495 		}
496 
497 		// Hard-wrap
498 		for (size_t i = 0; i < textLines.length; i++)
499 			if (textLines[i].length > w)
500 				textLines = textLines[0 .. i] ~ textLines[i][0 .. w] ~ textLines[i][w .. $] ~ textLines[i + 1 .. $];
501 
502 		// Scrolling and cursor upkeep
503 		{
504 			contentAreaHeight = h - 3;
505 			size_t contentHeight;
506 			final switch (mode)
507 			{
508 				case Mode.browser:
509 				case Mode.deleteConfirm:
510 				case Mode.deleteProgress:
511 				case Mode.deleteError:
512 					contentHeight = items.length;
513 					contentAreaHeight -= min(textLines.length, contentAreaHeight / 2);
514 					contentAreaHeight = min(contentAreaHeight, contentHeight + 1);
515 					break;
516 				case Mode.info:
517 				case Mode.help:
518 					contentHeight = textLines.length;
519 					break;
520 			}
521 
522 			// Ensure there is no unnecessary space at the bottom
523 			if (top + contentAreaHeight > contentHeight)
524 				top = contentHeight - contentAreaHeight;
525 			// Ensure we are never scrolled "above" the first row
526 			if (top < 0)
527 				top = 0;
528 
529 			final switch (mode)
530 			{
531 				case Mode.browser:
532 				case Mode.deleteConfirm:
533 				case Mode.deleteProgress:
534 				case Mode.deleteError:
535 				{
536 					// Ensure the selected item is visible
537 					auto pos = selection && items ? items.countUntil(selection) : 0;
538 					top = top.clamp(
539 						pos - contentAreaHeight + 1,
540 						pos,
541 					);
542 					break;
543 				}
544 				case Mode.info:
545 				case Mode.help:
546 					break;
547 			}
548 		}
549 
550 		// Rendering
551 		sizediff_t minWidth;
552 		{
553 			erase();
554 
555 			minWidth =
556 				"  100.0 KiB ".length +
557 				[
558 					""                    .length,
559 					"[##########] "       .length,
560 					"[100.0%] "           .length,
561 					"[100.0% ##########] ".length,
562 				][ratioDisplayMode] +
563 				"/".length +
564 				6;
565 
566 			if (h < 10 || w < minWidth)
567 			{
568 				mvprintw(0, 0, "Window too small");
569 				refresh();
570 				return;
571 			}
572 
573 			attron(A_REVERSE);
574 			mvhline(0, 0, ' ', w);
575 			mvprintw(0, 0, " btdu v" ~ btduVersion ~ " @ %.*s", fsPath.length, fsPath.ptr);
576 			if (imported)
577 				mvprintw(0, w - 10, " [IMPORT] ");
578 			else
579 			if (paused)
580 				mvprintw(0, w - 10, " [PAUSED] ");
581 
582 			mvhline(h - 1, 0, ' ', w);
583 			if (message && MonoTime.currTime < showMessageUntil)
584 				mvprintw(h - 1, 0, " %.*s", message.length, message.ptr);
585 			else
586 			{
587 				auto resolution = totalSamples
588 					? "~" ~ (totalSize / totalSamples).humanSize()
589 					: "-";
590 				mvprintw(h - 1, 0,
591 					" Samples: %lld  Resolution: %.*s",
592 					cast(cpp_longlong)totalSamples,
593 					resolution.length, resolution.ptr,
594 				);
595 			}
596 			attroff(A_REVERSE);
597 
598 			string prefix = "";
599 			final switch (mode)
600 			{
601 				case Mode.info:
602 					prefix = "INFO: ";
603 					goto case;
604 				case Mode.browser:
605 				case Mode.deleteConfirm:
606 				case Mode.deleteProgress:
607 				case Mode.deleteError:
608 					auto displayedPath = currentPath is &browserRoot ? "/" : currentPath.pointerWriter.text;
609 					auto maxPathWidth = w - 8 - prefix.length;
610 					if (displayedPath.length > maxPathWidth)
611 						displayedPath = "..." ~ displayedPath[$ - (maxPathWidth - 3) .. $];
612 
613 					mvhline(1, 0, '-', w);
614 					mvprintw(1, 3,
615 						" %s%.*s ",
616 						prefix.ptr,
617 						displayedPath.length, displayedPath.ptr,
618 					);
619 					break;
620 				case Mode.help:
621 					break;
622 			}
623 		}
624 
625 		final switch (mode)
626 		{
627 			case Mode.browser:
628 			case Mode.deleteConfirm:
629 			case Mode.deleteProgress:
630 			case Mode.deleteError:
631 			{
632 				auto mostSamples = items.fold!((a, b) => max(a, b.data[SampleType.represented].samples))(0UL);
633 
634 				foreach (i, child; items)
635 				{
636 					auto y = cast(int)(i - top);
637 					if (y < 0 || y >= contentAreaHeight)
638 						continue;
639 					y += 2;
640 
641 					if (child is selection)
642 						attron(A_REVERSE);
643 					else
644 						attroff(A_REVERSE);
645 					mvhline(y, 0, ' ', w);
646 
647 					buf.clear();
648 					{
649 						auto size = totalSamples
650 							? "~" ~ humanSize(child.data[SampleType.represented].samples * real(totalSize) / totalSamples)
651 							: "?";
652 						buf.formattedWrite!"%12s "(size);
653 					}
654 
655 					if (ratioDisplayMode)
656 					{
657 						buf.put('[');
658 						if (ratioDisplayMode & RatioDisplayMode.percentage)
659 						{
660 							if (currentPath.data[SampleType.represented].samples)
661 								buf.formattedWrite!"%5.1f%%"(100.0 * child.data[SampleType.represented].samples / currentPath.data[SampleType.represented].samples);
662 							else
663 								buf.put("    -%");
664 						}
665 						if (ratioDisplayMode == RatioDisplayMode.both)
666 							buf.put(' ');
667 						if (ratioDisplayMode & RatioDisplayMode.graph)
668 						{
669 							char[10] bar;
670 							if (mostSamples)
671 							{
672 								auto barPos = 10 * child.data[SampleType.represented].samples / mostSamples;
673 								bar[0 .. barPos] = '#';
674 								bar[barPos .. $] = ' ';
675 							}
676 							else
677 								bar[] = '-';
678 							buf.put(bar[]);
679 						}
680 						buf.put("] ");
681 					}
682 					buf.put(child.firstChild is null ? ' ' : '/');
683 
684 					{
685 						auto displayedItem = child.humanName;
686 						if (child.name[].startsWith("\0"))
687 							displayedItem = "<" ~ displayedItem ~ ">";
688 						auto maxItemWidth = w - (minWidth - 5);
689 						if (displayedItem.length > maxItemWidth)
690 						{
691 							auto leftLength = (maxItemWidth - "...".length) / 2;
692 							auto rightLength = maxItemWidth - "...".length - leftLength;
693 							displayedItem =
694 								displayedItem[0 .. leftLength] ~ "..." ~
695 								displayedItem[$ - rightLength .. $];
696 						}
697 						buf.put(displayedItem);
698 					}
699 
700 					rawWrite(y, 0, buf.get(), child is selection ? A_REVERSE : 0);
701 				}
702 				attroff(A_REVERSE);
703 
704 				foreach (i, line; textLines)
705 				{
706 					auto y = cast(int)(contentAreaHeight + i);
707 					y += 2;
708 					if (y == h - 2 && i + 1 < textLines.length)
709 					{
710 						mvprintw(y, 0, " --- more - press i to view --- ");
711 						break;
712 					}
713 					mvhline(y, 0, i ? ' ' : '-', w);
714 					mvprintw(y, 0, "%.*s", line.length, line.ptr);
715 				}
716 				break;
717 			}
718 
719 			case Mode.info:
720 			case Mode.help:
721 				foreach (i, line; textLines)
722 				{
723 					auto y = cast(int)(i - top);
724 					if (y < 0 || y >= contentAreaHeight)
725 						continue;
726 					y += 2;
727 					mvprintw(y, 0, "%.*s", line.length, line.ptr);
728 				}
729 				break;
730 		}
731 
732 		refresh();
733 
734 		// Pop-up
735 		(){
736 			dstring[] lines;
737 
738 			final switch (mode)
739 			{
740 				case Mode.browser:
741 				case Mode.info:
742 				case Mode.help:
743 					return;
744 
745 				case Mode.deleteConfirm:
746 					lines = [
747 						"Are you sure you want to delete:"d,
748 						null,
749 						getFullPath(selection).to!dstring,
750 						null,
751 						"Press Shift+Y to confirm,",
752 						"any other key to cancel.",
753 					];
754 					break;
755 
756 				case Mode.deleteProgress:
757 					synchronized(deleteThread) lines = [
758 						deleteStop ? "Stopping deletion:"d : "Deleting:"d,
759 						null,
760 						deleteCurrent.to!dstring,
761 						null,
762 						"Press Esc or q to cancel.",
763 					];
764 					break;
765 
766 				case Mode.deleteError:
767 					lines = [
768 						"Error deleting:"d,
769 						null,
770 						deleteCurrent.to!dstring,
771 						null,
772 						deleteError.to!dstring,
773 						null,
774 						"Displayed usage may be inaccurate;"d,
775 						"please restart btdu."d,
776 					];
777 					break;
778 			}
779 
780 			auto maxW = w - 6;
781 			for (size_t i = 0; i < lines.length; i++)
782 			{
783 				auto line = lines[i];
784 				if (line.length > maxW)
785 				{
786 					auto p = line[0 .. maxW].lastIndexOf('/');
787 					if (p < 0)
788 						p = line[0 .. maxW].lastIndexOf(' ');
789 					if (p < 0)
790 						p = maxW;
791 					lines = lines[0 .. i] ~ line[0 .. p] ~ line[p .. $] ~ lines[i + 1 .. $];
792 				}
793 			}
794 
795 			auto winW = (lines.map!(line => line.length).reduce!max + 6).to!int;
796 			auto winH = (lines.length + 4).to!int;
797 			auto winX = (w - winW) / 2;
798 			auto winY = (h - winH) / 2;
799 			auto win = newwin(winH, winW, winY, winX);
800 			scope(exit) delwin(win);
801 
802 			box(win, 0, 0);
803 			foreach (y, line; lines)
804 			{
805 				auto s = line.to!string;
806 				mvwprintw(win, (2 + y).to!int, 3, "%.*s", s.length, s.ptr);
807 			}
808 
809 			wrefresh(win);
810 		}();
811 	}
812 
813 	void moveCursor(sizediff_t delta)
814 	{
815 		if (!items.length)
816 			return;
817 		auto pos = items.countUntil(selection);
818 		if (pos < 0)
819 			return;
820 		pos += delta;
821 		if (pos < 0)
822 			pos = 0;
823 		if (pos > items.length - 1)
824 			pos = items.length - 1;
825 		selection = items[pos];
826 	}
827 
828 	// https://github.com/D-Programming-Deimos/ncurses/pull/43
829 	align(1)
830 	struct cchar_t
831 	{
832 		attr_t attr;
833 		wchar_t[CCHARW_MAX] chars;
834 	}
835 
836 	static cchar_t toCChar(dchar c, uint attr)
837 	{
838 		dchar[2] d = [c, 0];
839 		cchar_t cchar;
840 		if (setcchar(cast(deimos.ncurses.curses.cchar_t*)&cchar, d.ptr, attr, 0, null) != OK)
841 			return toCChar('\U0000FFFD', attr);
842 		return cchar;
843 	}
844 
845 	static void rawWrite(int y, int x, const(char)[] str, uint attr)
846 	{
847 		static FastAppender!cchar_t ccharBuf;
848 		ccharBuf.clear();
849 		foreach (dchar c; (cast(string)str).sanitize)
850 			ccharBuf.put(toCChar(c, attr));
851 		mvadd_wchnstr(y, x, cast(deimos.ncurses.curses.cchar_t*)ccharBuf.get().ptr, ccharBuf.get().length.to!int);
852 	}
853 
854 	/// Pausing has the following effects:
855 	/// 1. We send a SIGSTOP to subprocesses, so that they stop working ASAP.
856 	/// 2. We immediately stop reading subprocess output, so that the UI stops updating.
857 	/// 3. We display the paused state in the UI.
858 	void togglePause()
859 	{
860 		if (imported)
861 		{
862 			showMessage("Viewing an imported file, cannot pause / unpause");
863 			return;
864 		}
865 
866 		paused = !paused;
867 		foreach (ref subprocess; subprocesses)
868 			subprocess.pause(paused);
869 	}
870 
871 	void setSort(SortMode mode)
872 	{
873 		if (sortMode == mode)
874 			reverseSort = !reverseSort;
875 		else
876 		{
877 			sortMode = mode;
878 			reverseSort = false;
879 		}
880 
881 		bool ascending;
882 		final switch (sortMode)
883 		{
884 			case SortMode.name: ascending = !reverseSort; break;
885 			case SortMode.size: ascending =  reverseSort; break;
886 		}
887 
888 		showMessage(format("Sorting by %s (%s)", mode, ["descending", "ascending"][ascending]));
889 	}
890 
891 	bool handleInput()
892 	{
893 		auto ch = getch();
894 
895 		if (ch == ERR)
896 			return false; // no events - would have blocked
897 		else
898 			message = null;
899 
900 		switch (ch)
901 		{
902 			case 'p':
903 				togglePause();
904 				return true;
905 			case '?':
906 			case KEY_F0 + 1:
907 				mode = Mode.help;
908 				top = 0;
909 				break;
910 			default:
911 				// Proceed according to mode
912 		}
913 
914 		final switch (mode)
915 		{
916 			case Mode.browser:
917 				switch (ch)
918 				{
919 					case KEY_LEFT:
920 					case 'h':
921 					case '<':
922 						if (currentPath.parent)
923 						{
924 							selection = currentPath;
925 							currentPath = currentPath.parent;
926 							top = 0;
927 						}
928 						else
929 							showMessage("Already at top-level");
930 						break;
931 					case KEY_RIGHT:
932 					case '\n':
933 						if (selection)
934 						{
935 							currentPath = selection;
936 							selection = null;
937 							top = 0;
938 						}
939 						else
940 							showMessage("Nowhere to descend into");
941 						break;
942 					case KEY_UP:
943 					case 'k':
944 						moveCursor(-1);
945 						break;
946 					case KEY_DOWN:
947 					case 'j':
948 						moveCursor(+1);
949 						break;
950 					case KEY_PPAGE:
951 						moveCursor(-contentAreaHeight);
952 						break;
953 					case KEY_NPAGE:
954 						moveCursor(+contentAreaHeight);
955 						break;
956 					case KEY_HOME:
957 						moveCursor(-items.length);
958 						break;
959 					case KEY_END:
960 						moveCursor(+items.length);
961 						break;
962 					case 'i':
963 						mode = Mode.info;
964 						top = 0;
965 						break;
966 					case 'q':
967 						done = true;
968 						break;
969 					case 'n':
970 						setSort(SortMode.name);
971 						break;
972 					case 's':
973 						setSort(SortMode.size);
974 						break;
975 					case 't':
976 						dirsFirst = !dirsFirst;
977 						showMessage(format("%s directories before files",
978 								dirsFirst ? "Sorting" : "Not sorting"));
979 						break;
980 					case 'g':
981 						ratioDisplayMode++;
982 						ratioDisplayMode %= enumLength!RatioDisplayMode;
983 						showMessage(format("Showing %s", ratioDisplayMode));
984 						break;
985 					case 'd':
986 						if (!selection)
987 						{
988 							showMessage("Nothing to delete.");
989 							break;
990 						}
991 						if (!getFullPath(selection))
992 						{
993 							showMessage("Cannot delete special node " ~ selection.humanName ~ ".");
994 							break;
995 						}
996 						mode = Mode.deleteConfirm;
997 						break;
998 					default:
999 						// TODO: show message
1000 						break;
1001 				}
1002 				break;
1003 
1004 			case Mode.info:
1005 				switch (ch)
1006 				{
1007 					case KEY_LEFT:
1008 					case 'h':
1009 					case '<':
1010 						mode = Mode.browser;
1011 						if (currentPath.parent)
1012 						{
1013 							selection = currentPath;
1014 							currentPath = currentPath.parent;
1015 							top = 0;
1016 						}
1017 						break;
1018 					case 'q':
1019 					case 27: // ESC
1020 						if (items.length)
1021 							goto case 'i';
1022 						else
1023 							goto case KEY_LEFT;
1024 					case 'i':
1025 						mode = Mode.browser;
1026 						top = 0;
1027 						break;
1028 
1029 					default:
1030 						goto textScroll;
1031 				}
1032 				break;
1033 
1034 			case Mode.help:
1035 				switch (ch)
1036 				{
1037 					case 'q':
1038 					case 27: // ESC
1039 						mode = Mode.browser;
1040 						top = 0;
1041 						break;
1042 
1043 					default:
1044 						goto textScroll;
1045 				}
1046 				break;
1047 
1048 			case Mode.deleteConfirm:
1049 				switch (ch)
1050 				{
1051 					case 'Y':
1052 						auto path = getFullPath(selection).idup;
1053 						deleteCurrent = path;
1054 						deleteStop = false;
1055 						deleteThread = new Thread({
1056 							listDir!((e) {
1057 								synchronized(deleteThread)
1058 									deleteCurrent = e.fullName;
1059 
1060 								if (deleteStop)
1061 								{
1062 									// e.stop();
1063 									// return;
1064 									throw new Exception("User abort");
1065 								}
1066 
1067 								if (e.entryIsDir)
1068 									e.recurse();
1069 
1070 								int ret = unlinkat(e.dirFD, e.baseNameFSPtr,
1071 									e.entryIsDir ? AT_REMOVEDIR : 0);
1072 								errnoEnforce(ret == 0, "unlinkat failed");
1073 							}, Yes.includeRoot)(path);
1074 						});
1075 						deleteThread.start();
1076 						mode = Mode.deleteProgress;
1077 						break;
1078 
1079 					default:
1080 						mode = Mode.browser;
1081 						showMessage("Delete operation cancelled.");
1082 						break;
1083 				}
1084 				break;
1085 
1086 			case Mode.deleteProgress:
1087 				switch (ch)
1088 				{
1089 					case 'q':
1090 					case 27: // ESC
1091 						deleteStop = true;
1092 						break;
1093 
1094 					default:
1095 						// TODO: show message
1096 						break;
1097 				}
1098 				break;
1099 
1100 			case Mode.deleteError:
1101 				switch (ch)
1102 				{
1103 					case 'q':
1104 					case 27: // ESC
1105 						mode = Mode.browser;
1106 						break;
1107 
1108 					default:
1109 						// TODO: show message
1110 						break;
1111 				}
1112 				break;
1113 
1114 			textScroll:
1115 				switch (ch)
1116 				{
1117 					case KEY_UP:
1118 					case 'k':
1119 						top += -1;
1120 						break;
1121 					case KEY_DOWN:
1122 					case 'j':
1123 						top += +1;
1124 						break;
1125 					case KEY_PPAGE:
1126 						top += -contentAreaHeight;
1127 						break;
1128 					case KEY_NPAGE:
1129 						top += +contentAreaHeight;
1130 						break;
1131 					case KEY_HOME:
1132 						top -= textLines.length;
1133 						break;
1134 					case KEY_END:
1135 						top += textLines.length;
1136 						break;
1137 					default:
1138 						// TODO: show message
1139 						break;
1140 				}
1141 				break;
1142 		}
1143 
1144 		return true;
1145 	}
1146 }
1147 
1148 private:
1149 
1150 // TODO: upstream into Druntime
1151 extern (C) int unlinkat(int fd, const(char)* pathname, int flags);
1152 enum AT_REMOVEDIR = 0x200;
1153 
1154 /// https://en.wikipedia.org/wiki/1.96
1155 // enum z_975 = normalDistributionInverse(0.975);
1156 enum z_975 = 1.96;
1157 
1158 // https://stackoverflow.com/q/69420422/21501
1159 // https://stats.stackexchange.com/q/546878/234615
1160 double estimateError(
1161 	/// Total samples
1162 	double n,
1163 	/// Samples within the item
1164 	double m,
1165 	/// Standard score for desired confidence
1166 	/// (default is for 95% confidence)
1167 	double z = z_975,
1168 )
1169 {
1170 	import std.math.algebraic : sqrt;
1171 
1172 	auto p = m / n;
1173 	auto q = 1 - p;
1174 
1175 	auto error = sqrt((p * q) / n);
1176 	return z * error;
1177 }
1178 
1179 string toDecimalString(Duration d)
1180 {
1181 	assert(d >= Duration.zero);
1182 	auto ticks = d.stdTime;
1183 	enum secondsPerTick = 1.seconds / 1.stdDur;
1184 	static assert(secondsPerTick == 10L ^^ 7);
1185 	return format!"%d.%07d seconds"(ticks / secondsPerTick, ticks % secondsPerTick);
1186 }
1187 
1188 /// Helper type for formatting pointers without passing their contents by-value.
1189 /// Helps preserve the SubPath invariant (which would be broken by copying).
1190 struct PointerWriter(T)
1191 {
1192 	T* ptr;
1193 	void toString(scope void delegate(const(char)[]) sink) const
1194 	{
1195 		ptr.toString(sink);
1196 	}
1197 }
1198 PointerWriter!T pointerWriter(T)(T* ptr) { return PointerWriter!T(ptr); }
1199 
1200 static immutable string[] help = q"EOF
1201 btdu - the sampling disk usage profiler for btrfs
1202 -------------------------------------------------
1203 
1204 Keys:
1205 
1206       F1, ? - Show this help screen
1207       Up, k - Move cursor up
1208     Down, j - Move cursor down
1209 Right/Enter - Open selected node
1210  Left, <, h - Return to parent node
1211           p - Pause/resume
1212           n - Sort by name (ascending/descending)
1213           s - Sort by size (ascending/descending)
1214           t - Toggle dirs before files when sorting
1215           g - Show percentage and/or graph
1216           i - Expand/collapse information panel
1217           d - Delete the selected file or directory
1218           q - Close information panel or quit btdu
1219 
1220 Press q to exit this help screen and return to btdu.
1221 
1222 For terminology explanations, see:
1223 https://github.com/CyberShadow/btdu/blob/master/CONCEPTS.md
1224 
1225 https://github.com/CyberShadow/btdu
1226 Created by: Vladimir Panteleev <https://cy.md/>
1227 EOF".splitLines;