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;