tkdu - 用Python重新实现的 ncdu
磁盘空间越来越少,可是不知道空间究竟被谁占了?
在Linux下有一个命令行小工具 ncdu,可以列出目录及其子目录所占磁盘空间的大小。首先,du 是一个古老的 UNIX 命令,可以打印一个目录及其子目录和文件所占磁盘空间的大小,它的名字是 disk usage的首字母缩写。ncdu 则使用了图形化界面,每次只列出一级目录中的内容,使用快捷键在目录树中导航。比起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 环境变量中的某个目录中就能从命令行运行了。
https://share.i-code.fun/share/iIkp2qI6
代码如下:
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()