tkdu - 用Python重新实现的 ncdu

发布时间 2024-01-10 16:47:48作者: fmcdr

磁盘空间越来越少,可是不知道空间究竟被谁占了?
在Linux下有一个命令行小工具 ncdu,可以列出目录及其子目录所占磁盘空间的大小。首先,du 是一个古老的 UNIX 命令,可以打印一个目录所占磁盘空间的大小,它的名字是 disk usage的首字母缩写。du 的局限在于它一次只能列出一个目录的大小。ncdu 则更进一步,它可以列出一整个目录树中,所有子目录和文件的大小。而且它使用了图形用户界面。没错,ncdu 是一个命令行程序,但是它确实有图形用户界面。它的界面是用 ncurses 库实现的,这也是它叫做 ncdu 的原因。

ncdu 有方便的快捷键,可以通过方向键右键进入子目录,左键返回上层目录,按 d 键可以直接删除文件或目录。用起来相当顺手。
可惜 ncdu 这么好的东西在 Windows 平台上并不能运行,它只能在类 UNIX 系统上运行。在Windows 上有一大堆号称能够清理垃圾的“大师”其实本身就是垃圾软件。

某天,我突然想,能不能自己实现呢?于是,下面的山寨仿制品就诞生了。

模仿 ncdu 的命名方式,我把自己的作品命名为 tkdu ,因为它是用 Python 写的,界面库使用 Python 内置的 tk (tkinter)。
tkdu 也是一个命令行程序,这意味着,只能打开黑黑的命令行窗口去运行它,但是它有自己独立的窗口,而不是显示在终端中。基本的语法是:

tkdu [dir]

扫描的目标通过 dir 参数指定, dir 参数是可选的,如果省略,它扫描的就是“当前目录”。

例:

tkdu d:\some\dir

tkdu 并没有完全实现 ncdu,它有一些自己的东西。

键盘命令:

上下键导航
右箭头 或者 回车键 - 进入选定的子目录
左箭头 或者 Esc - 返回上一级目录
d - 删除选定的文件或目录
o - 调用默认的程序打开选定的文件,这是 ncdu 没有的功能

用它来扫描系统盘需要以管理员权限运行,不然很多目录没有权限查看。当然,用它来删除系统盘的文件是比较危险的,你需要自行判断什么能删,什么不能删。不小心删掉了系统文件,大不了重装系统。如果删掉了重要的数据,损失就不好估计了。

下面的exe文件是用 pyinstaller 打包而成的, 扔到 PATH 环境变量中的某个目录中就能从命令行运行了。

代码如下:

import os
import sys
import shutil
import tkinter as tk
from tkinter import ttk
from tkinter.messagebox import askyesno
import subprocess
import platform

def open_file(path):
    if platform.system() == 'Darwin':
        subprocess.call(('open', path))
    elif platform.system() == 'Windows':
        os.startfile(path)
    else:
        subprocess.call(('xdg-open', path))

class FSNode:
    def __init__(self, path, parent=None):
        self.path = os.path.abspath(path)
        self.name = os.path.basename(self.path)
        self.size = 0
        self.parent = parent
        try:
            if os.path.isdir(path):
                self.subdir = []
                self.files = []
                for child in os.listdir(path):
                    child_node = FSNode(os.path.join(path, child), self)
                    self.insert(child_node)
                    self.size = self.size + child_node.getsize()
            else:
                self.size = os.path.getsize(path)
        except Exception as e:
            print(path, 'skiped:', e)

    def getsize(self):
        return self.size

    def getpath(self):
        return self.path
    
    def getname(self):
        return self.name

    def isdir(self):
        return hasattr(self, 'subdir')

    def isfile(self):
        return not self.isdir()
    
    def get_children(self):
        if self.isdir():
            return self.subdir + self.files
        return None

    # insert node into the tree
    def insert(self, node):
        target = self.files
        if node.isdir(): target = self.subdir
        inserted = False
        # the list is pre sorted
        for i in range(len(target)):
            if target[i].size < node.size:
                target.insert(i, node)
                inserted = True
                break
        if not inserted:
            target.append(node)

    # delete the specified node from the tree
    def remove(self, node):
        target = self.files
        if node.isdir():
            target = self.subdir
        self.reduce_parent_size(node)
        target.remove(node)

    # recursively update parent's size after deleting the child
    def reduce_parent_size(self, node):
        if node.parent:
            node.parent.size -= node.size
            self.reduce_parent_size(node.parent)
    
def make_node_title(node, max_size):
    title = '{size:>11}'.format(size=psize(node.getsize()))

    scale = 0
    if max_size > 0:
        scale = int(20 * node.size / max_size)
    progress = ' [' + '#' * scale
    progress += ' ' * (20 - scale) + '] '

    title += progress
    title += '/' if node.isdir() else ' '
    title += node.getname()

    return title

# display size in a human readable format
def psize(size):
    kb = 1024
    mb = kb * 1024
    gb = mb * 1024
    if size > gb: return '%.2f GB' % (size / gb)
    if size > mb: return '%.2f MB' % (size / mb)
    if size > kb: return '%.2f KB' % (size / kb)
    return '%d Byte' % size

class App(tk.Tk):
    def __init__(self, tree):
        super().__init__()
        self.tree = tree
        self.path_label = ttk.Label(self, text=self.tree.getpath())
        self.path_label.pack()
        self.node_list = tk.Listbox(self, font='monospace')
        self.node_list.pack(fill='both', expand=True, side='left')
        self.scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL,
                                       command=self.node_list.yview)
        self.scrollbar.pack(fill='y', expand=False, side='left')
        self.node_list['yscrollcommand'] = self.scrollbar.set
        self.geometry('700x480')
        self.title('TKDU')
        self.load_tree()
        self.node_list.focus()
        self.bind('q', self.exit)
        self.node_list.bind('<Return>', self.enter_sub)
        self.node_list.bind('<Right>', self.enter_sub)
        self.node_list.bind('<Escape>', self.pre_level)
        self.node_list.bind('<Left>', self.pre_level)
        self.node_list.bind('d', self.del_node)
        self.node_list.bind('o', self.openfile)
        
    def exit(self, event):
        sys.exit()

    # refresh the Listbox's items from tree
    def load_tree(self):
        self.node_list.delete(0, 'end')
        self.node_list.insert('end', ' ' * 35 + '..')
        nodes = self.tree.get_children()
        if nodes:
            max_size = max(map(lambda n: n.getsize(), nodes))
            for node in nodes:
                title = make_node_title(node, max_size)
                self.node_list.insert('end', title)
        self.node_list.select_set(0)
        self.path_label.config(text=self.tree.getpath())

    # return to the previous level directory
    def pre_level(self, evt=None):
        if self.tree.parent:
            self.tree = self.tree.parent
            self.load_tree()

    # enter the selected directory
    def enter_sub(self, event=None):
        statu, node = self.get_selected()
        if statu == 'parent':
            self.pre_level()
        if statu == 'success' and node.isdir():
            self.tree = node
            self.load_tree()

    def get_selected(self):
        selected = self.node_list.curselection()
        if selected:
            i = selected[0]
            if i == 0:
                node = self.tree.parent
                return ('parent', node)
            if i > 0:
                node_list = self.tree.get_children()
                return ('success', node_list[i-1])
        return ('failed', None)

    def openfile(self, event=None):
        statu,node = self.get_selected()
        if statu == 'success' and node.isfile():
            open_file(node.path)

    # delete selected node (and file|directory)
    def del_node(self, event=None):
        statu, node = self.get_selected()
        if statu == 'success':
            answer = askyesno(title='Warning',
                              message='"{path}" \nwill be deleted! Are you sure?'.format(path=node.getpath()))
            if answer:
                try:
                    if node.isdir(): shutil.rmtree(node.getpath())
                    else: os.remove(node.getpath())
                    self.tree.remove(node)
                    self.load_tree()
                except Exception as e:
                    print('delete {path} failed'.format(path=node.getpath()), e)
                    
if __name__ == '__main__':
    if len(sys.argv) < 2:
        p = os.getcwd()
    else:
        p = sys.argv[1]

    tree = FSNode(p)
    app = App(tree)
    app.mainloop()