sf 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. #!/usr/bin/env python3
  2. """ A simple file manager written in python """
  3. """ source of code is : https://gitlab.com/Yellowhat/sf """
  4. import curses
  5. import sys
  6. from argparse import ArgumentParser
  7. from curses import KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT
  8. from curses import KEY_NPAGE, KEY_PPAGE, KEY_ENTER, KEY_BACKSPACE
  9. from curses import KEY_RESIZE
  10. from curses.ascii import BEL, ESC
  11. from os import environ, makedirs
  12. from math import log
  13. from mimetypes import guess_type
  14. from pathlib import Path
  15. from shutil import which
  16. from stat import filemode
  17. from string import Template
  18. from subprocess import run, PIPE
  19. from tempfile import NamedTemporaryFile
  20. __version__ = "sf 1.1.1"
  21. EDITOR = environ.get("EDITOR", "nano")
  22. def fmt_size(size):
  23. """Format a bytes size in human format"""
  24. if size == 0:
  25. return f"{0:6d} B"
  26. units = ["B", "kB", "MB", "GB", "TB", "PB"]
  27. decms = [0, 0, 1, 2, 2, 2]
  28. exponent = min(int(log(size, 1024)), len(units) - 1)
  29. quotient = float(size) / 1024 ** exponent
  30. unit, ndecm = units[exponent], decms[exponent]
  31. return f"{quotient:6.{ndecm}f} {unit:>2s}"
  32. class Sf:
  33. """Sf class"""
  34. def __init__(self, dircur=None, showhidden=False):
  35. # (>=3.9) Sets the number of milliseconds to wait after reading an escape character
  36. if sys.version_info.minor >= 9:
  37. curses.set_escdelay(10)
  38. # Padding
  39. self.padl = 1
  40. self.padt = 2
  41. # Check progress option for cp/mv
  42. out = run("cp --help", shell=True, check=True, capture_output=True).stdout.decode("UTF-8")
  43. opt_g = "--progress-bar" if "--progress-bar" in out else ""
  44. # Keybindings
  45. self.keybindings = {
  46. **dict.fromkeys([ord("k"), KEY_UP], lambda: self.move_cursor(-1)),
  47. **dict.fromkeys([ord("j"), KEY_DOWN], lambda: self.move_cursor(+1)),
  48. KEY_PPAGE: lambda: self.move_cursor(-10),
  49. KEY_NPAGE: lambda: self.move_cursor(+10),
  50. ord("g"): lambda: self.move_cursor(-999999),
  51. ord("G"): lambda: self.move_cursor(+999999),
  52. **dict.fromkeys([ord("l"), KEY_RIGHT, 10, 13, KEY_ENTER, BEL], self.open),
  53. **dict.fromkeys([ord("h"), KEY_LEFT, KEY_BACKSPACE, ord("\b")], self.upper_dir),
  54. ord("."): self.togglehidden,
  55. **dict.fromkeys([ord("p"), ord(" ")], self.mark),
  56. ord("P"): lambda: self.mark(mark_all=True),
  57. ord("c"): lambda: self.mark_clean(refresh=True),
  58. ord("f"): lambda: self.create("file"),
  59. ord("n"): lambda: self.create("folder"),
  60. ord("r"): self.rename,
  61. ord("y"): lambda: self.run_cmd(Template(f"cp -vir {opt_g} $files $dest")),
  62. ord("m"): lambda: self.run_cmd(Template(f"mv -vi {opt_g} $files $dest")),
  63. ord("x"): lambda: self.run_cmd(Template("rm -rf $files")),
  64. KEY_RESIZE: self.show_dir,
  65. ord("e"): self.read_dir,
  66. ord("!"): self.open_shell,
  67. ord("/"): self.search,
  68. ord("~"): lambda: self.go_dir(Path.home()),
  69. ord("1"): lambda: self.go_dir(Path.home() / "Downloads"),
  70. ord("2"): lambda: self.go_dir(Path("/media/Backup")),
  71. }
  72. if which("fzf") is not None:
  73. self.keybindings[ord("d")] = self.fzf
  74. self.window = None
  75. self.width = 0
  76. self.idxcur = 0
  77. self.dircur = Path(dircur if dircur is not None and Path(dircur).is_dir() else ".").resolve()
  78. self.showhidden = showhidden
  79. self.items = list()
  80. self.nitems = 0
  81. self.marked = set()
  82. self.mark_file = "/tmp/.sf_marked"
  83. def mainloop(self, window):
  84. """Initialise curses, show and wait for key to be pressed"""
  85. self.window = window
  86. curses.start_color()
  87. curses.use_default_colors()
  88. curses.curs_set(False)
  89. # Header
  90. curses.init_pair(1, 7, 4) # Header: White | Light Blue
  91. curses.init_pair(2, 7, 5) # Header: White | Light Blue
  92. # Colors (Standard)
  93. curses.init_pair(11, 248, 0) # Info: Grey | Black
  94. curses.init_pair(12, 6, 0) # Folder (Sym): Blue | Black
  95. curses.init_pair(13, 12, 0) # Folder: Blue | Black
  96. curses.init_pair(14, 10, 0) # Exec: Green | Black
  97. curses.init_pair(15, 5, 0) # Sym: Green | Black
  98. # Colors (Select)
  99. curses.init_pair(21, 1, 0) # Info: Grey | Grey
  100. self.mark_load()
  101. self.read_dir()
  102. while True:
  103. key = self.window.getch()
  104. if key == ord("q"):
  105. break
  106. self.keybindings.get(key, lambda: None)()
  107. def print_line(self, irow, path):
  108. """Print an item of the self.items list"""
  109. stat = filemode(path.stat().st_mode)
  110. if path.is_dir():
  111. size = "%9d" % sum(1 for p in path.glob("*"))
  112. else:
  113. size = fmt_size(path.stat().st_size)
  114. self.window.addstr(irow + self.padt, self.padl, f"{stat} {size} ", curses.color_pair(11))
  115. text = f"*{path.name}" if path in self.marked else f" {path.name}"
  116. if path.is_dir() and path.is_symlink():
  117. self.window.addnstr(text, self.width, curses.A_BOLD | curses.color_pair(12))
  118. elif path.is_dir():
  119. self.window.addnstr(text, self.width, curses.A_BOLD | curses.color_pair(13))
  120. elif which(path) is not None:
  121. self.window.addnstr(text, self.width, curses.color_pair(14))
  122. elif path.is_symlink():
  123. self.window.addnstr(text, self.width, curses.color_pair(15))
  124. else:
  125. self.window.addnstr(text, self.width)
  126. def show_dir(self, header=None):
  127. """Show the content of the current folder"""
  128. self.window.clear()
  129. term_l, term_w = self.window.getmaxyx()
  130. maxitems = term_l - self.padt
  131. self.width = term_w - self.padl - 9 - 1 - 9 - 2 - 1
  132. if header is None:
  133. header = f"{self.idxcur + 1:4d}/{self.nitems} {self.dircur} "
  134. self.window.addnstr(0, self.padl, header, term_w - 1, curses.color_pair(1))
  135. if self.items:
  136. nshow = 3
  137. if maxitems - self.idxcur < nshow:
  138. idx_end = self.idxcur + nshow
  139. idx_start = idx_end - maxitems
  140. else:
  141. idx_start = 0
  142. idx_end = maxitems
  143. path_cur = self.items[self.idxcur]
  144. sep = 10 + 10 + 2
  145. for irow, path in enumerate(self.items[idx_start:idx_end]):
  146. self.print_line(irow, path)
  147. if path == path_cur:
  148. self.window.move(irow + self.padt, self.padl)
  149. self.window.chgat(sep, curses.color_pair(21))
  150. self.window.move(irow + self.padt, self.padl + sep)
  151. self.window.chgat(len(path.name), curses.A_REVERSE)
  152. else:
  153. self.window.addstr(self.padt, self.padl, "empty", curses.color_pair(12))
  154. self.window.refresh()
  155. def read_dir(self):
  156. """Read current folder content"""
  157. dirs = list()
  158. files = list()
  159. for path in Path(self.dircur).glob("*"):
  160. if (not path.name.startswith(".")) or self.showhidden:
  161. if path.is_dir():
  162. dirs.append(path)
  163. else:
  164. files.append(path)
  165. dirs.sort(key=lambda x: x.name.lower())
  166. files.sort(key=lambda x: x.stem.lower())
  167. self.idxcur = 0
  168. self.items = dirs + files
  169. self.nitems = len(self.items)
  170. self.show_dir()
  171. def move_cursor(self, nrows):
  172. """Move the cursors by nrows"""
  173. self.idxcur += nrows
  174. if self.idxcur < 0:
  175. self.idxcur = self.nitems - 1
  176. elif self.idxcur > self.nitems - 1:
  177. self.idxcur = 0
  178. self.show_dir()
  179. def go_dir(self, path):
  180. """Go to folder"""
  181. self.dircur = path.resolve()
  182. if self.dircur.is_file():
  183. self.dircur = self.dircur.parent
  184. self.idxcur = 0
  185. self.read_dir()
  186. def upper_dir(self):
  187. """Go to the upper folder"""
  188. self.go_dir(self.dircur.parent)
  189. def open_shell(self):
  190. """Open a terminal in the current folder"""
  191. curses.endwin()
  192. _ = run(f"cd '{self.dircur}'; $0", shell=True, check=True)
  193. self.window.refresh()
  194. def open_app(self, path):
  195. """Open the current item in an external application"""
  196. mimetype = guess_type(path)[0]
  197. path = '"' + path.absolute().as_posix() + '"'
  198. if mimetype is None:
  199. cmd = f"{EDITOR} {path}"
  200. else:
  201. typ, subtyp = mimetype.split("/")
  202. if typ in ["image", "video"]:
  203. cmd = f"nohup mpv {path} &>/dev/null &"
  204. elif typ == "audio":
  205. cmd = f"nohup mpv --no-video {path} &>/dev/null &"
  206. elif typ == "text" or subtyp in ["x-sh"]:
  207. cmd = f"{EDITOR} {path}"
  208. elif any(subtyp.startswith(s) for s in ["vnd.oasis", "vnd.open", "vnd.ms-", "msword"]):
  209. cmd = f"nohup dbus-launch flatpak run org.libreoffice.LibreOffice {path} &>/dev/null &"
  210. elif subtyp.startswith("pdf"):
  211. cmd = f"nohup zathura {path} &>/dev/null &"
  212. else:
  213. cmd = f"nohup xdg-open {path} &>dev/null &"
  214. curses.endwin()
  215. _ = run(cmd, shell=True, check=True)
  216. self.window.refresh()
  217. def open(self):
  218. """Open the current item"""
  219. if not self.items:
  220. return
  221. path = self.items[self.idxcur]
  222. if path.is_dir():
  223. self.go_dir(path.resolve())
  224. else:
  225. self.open_app(path)
  226. self.show_dir()
  227. def togglehidden(self):
  228. """Toggle to show/hide hidden items"""
  229. self.showhidden = not self.showhidden
  230. self.read_dir()
  231. def mark(self, mark_all=None):
  232. """Mark an or all items and save to file the new set"""
  233. if mark_all is not None:
  234. paths = self.items
  235. else:
  236. paths = [self.items[self.idxcur]]
  237. for path in paths:
  238. if path in self.marked:
  239. self.marked.remove(path)
  240. else:
  241. self.marked.add(path)
  242. with open(self.mark_file, "w") as fobj:
  243. for path in self.marked:
  244. fobj.write(str(path) + "\n")
  245. self.show_dir()
  246. def mark_load(self):
  247. """Load marked items set from file"""
  248. if not Path(self.mark_file).is_file():
  249. self.mark_clean()
  250. return
  251. if Path(self.mark_file).stat().st_size == 0:
  252. self.mark_clean()
  253. return
  254. with open(self.mark_file, "r") as fobj:
  255. self.marked = {Path(s.strip()) for s in fobj.readlines()}
  256. def mark_clean(self, refresh=False):
  257. """Empty marked items set"""
  258. self.marked = set()
  259. open(self.mark_file, "w").close()
  260. if refresh:
  261. self.show_dir()
  262. def prompt(self, prompt):
  263. """Prompt user for a string"""
  264. self.window.addstr(0, self.padl, prompt, curses.color_pair(2))
  265. self.window.clrtoeol()
  266. response = ""
  267. while True:
  268. key = self.window.getch()
  269. if key == ESC:
  270. return None
  271. if key in [10, KEY_ENTER]:
  272. return response
  273. if key in [KEY_BACKSPACE, ord("\b")]:
  274. response = response[:-1]
  275. else:
  276. response += chr(key)
  277. self.window.addstr(0, self.padl + len(prompt), response, curses.color_pair(1))
  278. self.window.clrtoeol()
  279. def prompt_yesno(self, header, extra):
  280. """Prompt user for yes/no"""
  281. header += ", confirm with y?"
  282. curses.echo()
  283. self.window.clear()
  284. self.window.addstr(0, self.padl, header, curses.color_pair(1))
  285. i = 1
  286. for row in extra:
  287. self.window.addstr(i, self.padl, str(row), curses.color_pair(1))
  288. i += 1
  289. self.window.refresh()
  290. curses.noecho()
  291. key = self.window.getch()
  292. return bool(key == ord("y"))
  293. def create(self, typ):
  294. """Create a new empty file/folder if not existing"""
  295. name = self.prompt(f"New {typ} name: ")
  296. if name is None:
  297. self.show_dir()
  298. return
  299. path = Path(self.dircur) / Path(name)
  300. if not path.exists():
  301. if typ == "file":
  302. open(path, "a").close()
  303. elif typ == "folder":
  304. makedirs(path)
  305. self.read_dir()
  306. def rename(self):
  307. """Rename all items in current folder"""
  308. if not self.items:
  309. return
  310. with NamedTemporaryFile(mode="w+") as temp_file:
  311. for item in self.items:
  312. temp_file.write(item.name + "\n")
  313. temp_file.seek(0)
  314. curses.endwin()
  315. _ = run(f"{EDITOR} {temp_file.name}", shell=True, check=True)
  316. temp_file.seek(0)
  317. items_new = temp_file.read().splitlines()
  318. if len(items_new) == len(self.items):
  319. for src, dst in zip(self.items, items_new):
  320. src.rename(src.parent / dst)
  321. self.window.refresh()
  322. self.read_dir()
  323. def run_cmd(self, template):
  324. """Run a command in the shell (cp/mv/rm)"""
  325. self.mark_load()
  326. lst = self.marked if self.marked else [self.items[self.idxcur]]
  327. response = self.prompt_yesno(f"Run '{template.template.split()[0]}'", lst)
  328. curses.endwin()
  329. if response:
  330. files = " ".join(f'"{p.absolute()}"' for p in lst)
  331. cmd = template.substitute(files=files, dest=f"'{self.dircur}'")
  332. _ = run(cmd, shell=True, check=True)
  333. self.mark_clean()
  334. self.window.refresh()
  335. self.read_dir()
  336. def search(self):
  337. """Search in the current folder"""
  338. search = self.prompt("Search: ")
  339. if search is None:
  340. self.show_dir()
  341. else:
  342. self.items = [s for s in self.items if search in s.name]
  343. self.nitems = len(self.items)
  344. self.idxcur = 0
  345. header = f"Search: {search}"
  346. self.show_dir(header=header)
  347. def fzf(self):
  348. """Go to the folder of the file selected"""
  349. proc = run(f"find {self.dircur} | fzf", shell=True, check=False, stdout=PIPE, encoding="UTF-8")
  350. self.go_dir(path=Path(proc.stdout.strip()))
  351. if __name__ == "__main__":
  352. # Parse arguments
  353. parser = ArgumentParser(description="sf: A simple console file manager written in python")
  354. parser.add_argument("-v", "--version", action="version", version=__version__)
  355. parser.add_argument("folder", nargs="?", help="Folder to run from")
  356. parser.add_argument("--showhidden", default=False, action="store_true", help="Show hidden files")
  357. parser.add_argument("--no-showhidden", dest="showhidden", action="store_false", help="Hide hidden files (default)")
  358. args = parser.parse_args()
  359. # Mainloop
  360. sf = Sf(dircur=args.folder, showhidden=args.showhidden)
  361. try:
  362. curses.wrapper(sf.mainloop)
  363. except KeyboardInterrupt:
  364. pass