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