Make sync_from_filesystem asynchoronous to speed up startup of large projects.

- Fixed viewing projects with no files.
- Changed fill3.Column to make it work with sorted lists.
- Simplified Log class to not need fill3.Column.
- Eris results will be cleared when the tools config changes.
This commit is contained in:
Andrew Hamilton 2020-03-28 13:37:07 +10:00
parent c6f790c35c
commit 51489b35cd
5 changed files with 193 additions and 212 deletions

View file

@ -20,6 +20,8 @@ import asyncio
import contextlib import contextlib
import functools import functools
import gzip import gzip
import importlib
import importlib.resources
import math import math
import multiprocessing import multiprocessing
import os import os
@ -33,7 +35,9 @@ import time
import docopt import docopt
import pygments.styles import pygments.styles
import pyinotify import pyinotify
import sortedcontainers
import eris
from eris import fill3 from eris import fill3
from eris import terminal from eris import terminal
from eris import termstr from eris import termstr
@ -94,6 +98,7 @@ class Entry:
result.entry = self result.entry = self
self.widget = fill3.Row(results) self.widget = fill3.Row(results)
self.appearance_cache = None self.appearance_cache = None
self.last_width = None
def __eq__(self, other): def __eq__(self, other):
return self.path == other.path return self.path == other.path
@ -102,29 +107,21 @@ class Entry:
return len(self.widgets) return len(self.widgets)
def __getitem__(self, index): def __getitem__(self, index):
return self.widgets.__getitem__(index) return self.widgets[index]
def _get_cursor(self):
result_selected = self.widget[self.highlighted]
status_color = tools._STATUS_COLORS.get(
result_selected.status, None)
fg_color = (termstr.Color.white if result_selected.status ==
tools.Status.pending else termstr.Color.black)
return fill3.Text(termstr.TermStr("+", termstr.CharStyle(
fg_color=fg_color, bg_color=status_color)))
def appearance_min(self): def appearance_min(self):
# 'appearance' local variable exists because appearance_cache can
# become None at any time.
appearance = self.appearance_cache appearance = self.appearance_cache
if appearance is None: if appearance is None or self.last_width != self.summary._max_width:
self.last_width = self.summary._max_width
if self.highlighted is not None: if self.highlighted is not None:
self.widget[self.highlighted] = self._get_cursor() self.widget[self.highlighted].is_highlighted = True
new_appearance = self.widget.appearance_min() new_appearance = self.widget.appearance_min()
path = tools.path_colored(self.path) path = tools.path_colored(self.path)
padding = " " * (self.summary._max_width - len(self.widget) + 1) padding = " " * (self.last_width - len(self.widget) + 1)
new_appearance[0] = new_appearance[0] + padding + path new_appearance[0] = new_appearance[0] + padding + path
self.appearance_cache = appearance = new_appearance self.appearance_cache = appearance = new_appearance
if self.highlighted is not None:
self.widget[self.highlighted].is_highlighted = False
return appearance return appearance
def as_html(self): def as_html(self):
@ -156,8 +153,8 @@ def codebase_files(path, skip_hidden_directories=True):
def fix_paths(root_path, paths): def fix_paths(root_path, paths):
return [os.path.join(".", os.path.relpath(path, root_path)) return (os.path.join(".", os.path.relpath(path, root_path))
for path in paths] for path in paths)
def blend_color(a_color, b_color, transparency): def blend_color(a_color, b_color, transparency):
@ -204,26 +201,6 @@ def type_sort(entry):
os.path.basename(path)) os.path.basename(path))
def log_filesystem_changed(log, added, removed, modified):
def part(stat, text, color):
return termstr.TermStr(f"{stat:2} {text}.").fg_color(
termstr.Color.grey_100 if stat == 0 else color)
parts = [part(added, "added", termstr.Color.green),
part(removed, "removed", termstr.Color.red),
part(modified, "modified", termstr.Color.blue)]
log.log_message("Filesystem changed: " + fill3.join(" ", parts))
def get_diff_stats(old_files, new_files):
old_names = set(name for name, ctime in old_files)
new_names = set(name for name, ctime in new_files)
added_count = len(new_names - old_names)
removed_count = len(old_names - new_names)
same_count = len(new_names) - added_count
modified_count = same_count - len(old_files.intersection(new_files))
return added_count, removed_count, modified_count
class Summary: class Summary:
def __init__(self, root_path, jobs_added_event): def __init__(self, root_path, jobs_added_event):
@ -234,10 +211,11 @@ class Summary:
self.closest_placeholder_generator = None self.closest_placeholder_generator = None
self._cache = {} self._cache = {}
self.is_directory_sort = True self.is_directory_sort = True
self._max_width = None self._max_width = 0
self._max_path_length = None self._max_path_length = 0
self._all_results = set() self._entries = sortedcontainers.SortedList([], key=directory_sort)
self.sync_with_filesystem() self.result_total = 0
self.completed_total = 0
def __getstate__(self): def __getstate__(self):
state = self.__dict__.copy() state = self.__dict__.copy()
@ -256,68 +234,75 @@ class Summary:
self.closest_placeholder_generator = None self.closest_placeholder_generator = None
def sort_entries(self): def sort_entries(self):
self._column.sort(key=directory_sort if self.is_directory_sort key_func = directory_sort if self.is_directory_sort else type_sort
else type_sort) self._entries = sortedcontainers.SortedList(
self._entries, key=key_func)
self.closest_placeholder_generator = None self.closest_placeholder_generator = None
def file_added(self, path): def file_added(self, path):
full_path = os.path.join(self._root_path, path) full_path = os.path.join(self._root_path, path)
try: try:
file_key = (path, os.stat(full_path).st_ctime) change_time = os.stat(full_path).st_ctime
except FileNotFoundError: except FileNotFoundError:
return return
row = [] row = []
change_time = self._cache.setdefault(path, change_time)
for tool in tools.tools_for_path(path): for tool in tools.tools_for_path(path):
tool_key = (tool.__name__, tool.__code__.co_code)
result = tools.Result(path, tool) result = tools.Result(path, tool)
self._all_results.add(result)
self.result_total += 1 self.result_total += 1
file_entry = self._cache.setdefault(file_key, {})
file_entry[tool_key] = result
if result.is_completed: if result.is_completed:
self.completed_total += 1 self.completed_total += 1
row.append(result) row.append(result)
self._max_width = max(len(row), self._max_width) self._max_width = max(len(row), self._max_width)
self._max_path_length = max(len(path) - len("./"), self._max_path_length = max(len(path) - len("./"),
self._max_path_length) self._max_path_length)
self._column.append(Entry(path, row, self)) entry = Entry(path, row, self)
self.sort_entries() self._entries.add(entry)
entry_index = self._entries.index(entry)
x, y = self._cursor_position
if entry_index <= y:
self.scroll(0, -1)
self._jobs_added_event.set() self._jobs_added_event.set()
self.closest_placeholder_generator = None self.closest_placeholder_generator = None
def file_deleted(self, path): def file_deleted(self, path, check=True):
if check and os.path.exists(os.path.join(self._root_path, path)):
return
entry = Entry(path, [], self) entry = Entry(path, [], self)
try: try:
index = self._column.index(entry) index = self._entries.index(entry)
except ValueError: except ValueError:
return return
new_cache = {} x, y = self._cursor_position
for file_key in self._cache: if index < y:
cache_path, cache_time = file_key self.scroll(0, 1)
if cache_path != path: del self._cache[path]
new_cache[file_key] = self._cache[file_key] for result in self._entries[index]:
self._cache = new_cache
for result in self._column[index]:
self._all_results.remove(result)
if result.is_completed: if result.is_completed:
self.completed_total -= 1 self.completed_total -= 1
self.result_total -= 1 self.result_total -= 1
result.delete() result.delete()
row = self._column[index] row = self._entries[index]
del self._column[index] self._entries.pop(index)
if len(row) == self._max_width: if len(row) == self._max_width:
self._max_width = max(len(entry) for entry in self._column) self._max_width = max((len(entry) for entry in self._entries),
default=0)
if (len(path) - 2) == self._max_path_length: if (len(path) - 2) == self._max_path_length:
self._max_path_length = max((len(entry.path) - 2) self._max_path_length = max(((len(entry.path) - 2)
for entry in self._column) for entry in self._entries), default=0)
x, y = self._cursor_position x, y = self._cursor_position
if y == len(self._column): if y == len(self._entries):
self._cursor_position = x, y - 1 self._cursor_position = x, y - 1
self.closest_placeholder_generator = None self.closest_placeholder_generator = None
def file_modified(self, path): def file_modified(self, path):
self.file_deleted(path) entry = Entry(path, [], self)
self.file_added(path) try:
entry_index = self._entries.index(entry)
except ValueError:
return
for result in self._entries[entry_index]:
self.refresh_result(result, only_completed=False)
@contextlib.contextmanager @contextlib.contextmanager
def keep_selection(self): def keep_selection(self):
@ -328,77 +313,45 @@ class Summary:
return return
x, y = self._cursor_position x, y = self._cursor_position
yield yield
for index, row in enumerate(self._column): for index, row in enumerate(self._entries):
if row.path == cursor_path: if row.path == cursor_path:
self._cursor_position = (x, index) self._cursor_position = (x, index)
return return
if y >= len(self._column): if y >= len(self._entries):
self._cursor_position = (x, len(self._column) - 1) self._cursor_position = (x, len(self._entries) - 1)
def sync_with_filesystem(self, log=None): async def sync_with_filesystem(self, log=None):
new_column = fill3.Column([]) log.log_message("Started syncing filesystem…")
new_cache = {} start_time = time.time()
paths = fix_paths(self._root_path, codebase_files(self._root_path)) all_paths = set()
jobs_added = False for path in fix_paths(self._root_path, codebase_files(self._root_path)):
row_index = 0 await asyncio.sleep(0)
result_total, completed_total = 0, 0 all_paths.add(path)
all_results = set() if path in self._cache:
for path in paths: full_path = os.path.join(self._root_path, path)
full_path = os.path.join(self._root_path, path) change_time = os.stat(full_path).st_ctime
try: if change_time != self._cache[path]:
file_key = (path, os.stat(full_path).st_ctime) self._cache[path] = change_time
except FileNotFoundError: self.file_modified(path)
continue else:
row = [] self.file_added(path)
for tool in tools.tools_for_path(path): for path in self._cache.keys() - all_paths:
tool_key = (tool.__name__, tool.__code__.co_code) await asyncio.sleep(0)
if file_key in self._cache \ self.file_deleted(path)
and tool_key in self._cache[file_key]: duration = time.time() - start_time
result = self._cache[file_key][tool_key] log.log_message(f"Finished syncing filesystem. {round(duration, 2)} secs")
result.tool = tool
else:
result = tools.Result(path, tool)
jobs_added = True
all_results.add(result)
if result.is_completed:
completed_total += 1
file_entry = new_cache.setdefault(file_key, {})
file_entry[tool_key] = result
row.append(result)
new_column.append(Entry(path, row, self))
row_index += 1
result_total += len(row)
max_width = max(len(row) for row in new_column)
max_path_length = max(len(path) for path in paths) - len("./")
deleted_results = self._all_results - all_results
if log is not None:
stats = get_diff_stats(
set(self._cache.keys()), set(new_cache.keys()))
if sum(stats) != 0:
log_filesystem_changed(log, *stats)
with self.keep_selection():
(self._column, self._cache, self.result_total,
self.completed_total, self._max_width, self._max_path_length,
self.closest_placeholder_generator, self._all_results) = (
new_column, new_cache, result_total, completed_total,
max_width, max_path_length, None, all_results)
if jobs_added:
self._jobs_added_event.set()
for result in deleted_results:
result.delete()
self.sort_entries()
def _sweep_up(self, x, y): def _sweep_up(self, x, y):
yield from reversed(self._column[y][:x]) yield from reversed(self._entries[y][:x])
while True: while True:
y = (y - 1) % len(self._column) y = (y - 1) % len(self._entries)
yield from reversed(self._column[y]) yield from reversed(self._entries[y])
def _sweep_down(self, x, y): def _sweep_down(self, x, y):
yield from self._column[y][x:] yield from self._entries[y][x:]
while True: while True:
y = (y + 1) % len(self._column) y = (y + 1) % len(self._entries)
yield from self._column[y] yield from self._entries[y]
def _sweep_combined(self, x, y): def _sweep_combined(self, x, y):
for up_result, down_result in zip(self._sweep_up(x, y), for up_result, down_result in zip(self._sweep_up(x, y),
@ -423,15 +376,17 @@ class Summary:
return await self.closest_placeholder_generator.asend(None) return await self.closest_placeholder_generator.asend(None)
def appearance_dimensions(self): def appearance_dimensions(self):
return self._max_path_length + 1 + self._max_width, len(self._column) return self._max_path_length + 1 + self._max_width, len(self._entries)
def appearance_interval(self, interval): def appearance_interval(self, interval):
start_y, end_y = interval start_y, end_y = interval
x, y = self.cursor_position() x, y = self.cursor_position()
rows = fill3.Column(self._column.widgets) self._entries[y].highlighted = x
rows[y] = Entry(rows[y].path, rows[y].widgets, self, highlighted=x, self._entries[y].appearance_cache = None
set_results=False) appearance = fill3.Column(self._entries).appearance_interval(interval)
return rows.appearance_interval(interval) self._entries[y].highlighted = None
self._entries[y].appearance_cache = None
return appearance
def _set_scroll_position(self, cursor_x, cursor_y, summary_height): def _set_scroll_position(self, cursor_x, cursor_y, summary_height):
scroll_x, scroll_y = new_scroll_x, new_scroll_y = \ scroll_x, scroll_y = new_scroll_x, new_scroll_y = \
@ -452,6 +407,8 @@ class Summary:
def appearance(self, dimensions): def appearance(self, dimensions):
width, height = dimensions width, height = dimensions
if len(self._entries) == 0:
return [" " * width for row in range(height)]
cursor_x, cursor_y = self.cursor_position() cursor_x, cursor_y = self.cursor_position()
width, height = width - 1, height - 1 # Minus one for the scrollbars width, height = width - 1, height - 1 # Minus one for the scrollbars
self._set_scroll_position(cursor_x, cursor_y, height) self._set_scroll_position(cursor_x, cursor_y, height)
@ -466,20 +423,23 @@ class Summary:
def cursor_position(self): def cursor_position(self):
x, y = self._cursor_position x, y = self._cursor_position
return min(x, len(self._column[y])-1), y try:
return min(x, len(self._entries[y])-1), y
except IndexError:
return 0, 0
def get_selection(self): def get_selection(self):
x, y = self.cursor_position() x, y = self.cursor_position()
return self._column[y][x] return self._entries[y][x]
def _move_cursor(self, vector): def _move_cursor(self, vector):
dx, dy = vector dx, dy = vector
if dy == 0: if dy == 0:
x, y = self.cursor_position() x, y = self.cursor_position()
self._cursor_position = ((x + dx) % len(self._column[y]), y) self._cursor_position = ((x + dx) % len(self._entries[y]), y)
elif dx == 0: elif dx == 0:
x, y = self._cursor_position x, y = self._cursor_position
self._cursor_position = (x, (y + dy) % len(self._column)) self._cursor_position = (x, (y + dy) % len(self._entries))
else: else:
raise ValueError raise ValueError
@ -509,17 +469,17 @@ class Summary:
def cursor_end(self): def cursor_end(self):
x, y = self._cursor_position x, y = self._cursor_position
self._cursor_position = x, len(self._column) - 1 self._cursor_position = x, len(self._entries) - 1
def _issue_generator(self): def _issue_generator(self):
x, y = self.cursor_position() x, y = self.cursor_position()
for index in range(len(self._column) + 1): for index in range(len(self._entries) + 1):
row_index = (index + y) % len(self._column) row_index = (index + y) % len(self._entries)
row = self._column[row_index] row = self._entries[row_index]
for index_x, result in enumerate(row): for index_x, result in enumerate(row):
if (result.status == tools.Status.problem and if (result.status == tools.Status.problem and
not (row_index == y and index_x <= x and not (row_index == y and index_x <= x and
index != len(self._column))): index != len(self._entries))):
yield result, (index_x, row_index) yield result, (index_x, row_index)
def move_to_next_issue(self): def move_to_next_issue(self):
@ -533,22 +493,23 @@ class Summary:
self._cursor_position = position self._cursor_position = position
return return
def refresh_result(self, result): def refresh_result(self, result, only_completed=True):
if result.is_completed: if result.is_completed or not only_completed:
if result.is_completed:
self.completed_total -= 1
result.reset() result.reset()
result.delete() result.delete()
self.closest_placeholder_generator = None self.closest_placeholder_generator = None
self._jobs_added_event.set() self._jobs_added_event.set()
self.completed_total -= 1
def refresh_tool(self, tool): def refresh_tool(self, tool):
for row in self._column: for row in self._entries:
for result in row: for result in row:
if result.tool == tool: if result.tool == tool:
self.refresh_result(result) self.refresh_result(result)
def clear_running(self): def clear_running(self):
for row in self._column: for row in self._entries:
for result in row: for result in row:
if result.status == tools.Status.running: if result.status == tools.Status.running:
self.refresh_result(result) self.refresh_result(result)
@ -556,7 +517,7 @@ class Summary:
def as_html(self): def as_html(self):
html_parts = [] html_parts = []
styles = set() styles = set()
for row in self._column: for row in self._entries:
html_row, styles_row = row.as_html() html_row, styles_row = row.as_html()
html_parts.append(html_row) html_parts.append(html_row)
styles.update(styles_row) styles.update(styles_row)
@ -572,9 +533,8 @@ class Log:
def __init__(self, appearance_changed_event): def __init__(self, appearance_changed_event):
self._appearance_changed_event = appearance_changed_event self._appearance_changed_event = appearance_changed_event
self.widget = fill3.Column([]) self.lines = []
self.portal = fill3.Portal(self.widget) self._appearance = None
self._appearance_cache = None
def __getstate__(self): def __getstate__(self):
state = self.__dict__.copy() state = self.__dict__.copy()
@ -591,11 +551,10 @@ class Log:
timestamp = (time.strftime("%H:%M:%S", time.localtime()) timestamp = (time.strftime("%H:%M:%S", time.localtime())
if timestamp is None else timestamp) if timestamp is None else timestamp)
line = termstr.TermStr(timestamp, Log._GREY_BOLD_STYLE) + " " + message line = termstr.TermStr(timestamp, Log._GREY_BOLD_STYLE) + " " + message
self.widget.append(fill3.Text(line)) self.lines.append(line)
with open(Log.LOG_PATH, "a") as log_file: with open(Log.LOG_PATH, "a") as log_file:
print(line, file=log_file) print(line, file=log_file)
self.widget.widgets = self.widget[-200:] self._appearance = None
self._appearance_cache = None
self._appearance_changed_event.set() self._appearance_changed_event.set()
def log_command(self, message, timestamp=None): def log_command(self, message, timestamp=None):
@ -605,17 +564,13 @@ class Log:
with contextlib.suppress(FileNotFoundError): with contextlib.suppress(FileNotFoundError):
os.remove(Log.LOG_PATH) os.remove(Log.LOG_PATH)
def appearance_min(self):
appearance = self._appearance_cache
if appearance is None:
self._appearance_cache = appearance = self.widget.appearance_min()
return appearance
def appearance(self, dimensions): def appearance(self, dimensions):
width, height = dimensions if self._appearance is None or \
full_appearance = self.appearance_min() fill3.appearance_dimensions(self._appearance) != dimensions:
self.portal.position = (0, max(0, len(full_appearance) - height)) width, height = dimensions
return self.portal.appearance(dimensions) self.lines = self.lines[-height:]
self._appearance = fill3.appearance_resize(self.lines, dimensions)
return self._appearance
def highlight_chars(str_, style, marker="*"): def highlight_chars(str_, style, marker="*"):
@ -748,8 +703,12 @@ class Screen:
root_path = os.path.basename(self._summary._root_path) root_path = os.path.basename(self._summary._root_path)
summary = fill3.Border(self._summary, title="Summary of " + root_path) summary = fill3.Border(self._summary, title="Summary of " + root_path)
self._summary_border = summary self._summary_border = summary
selected_widget = self._summary.get_selection() try:
self._view = fill3.View.from_widget(selected_widget.result) selected_widget = self._summary.get_selection()
result_widget = selected_widget.result
except IndexError:
result_widget = fill3.Text("Nothing selected")
self._view = fill3.View.from_widget(result_widget)
self._listing = fill3.Border(Listing(self._view)) self._listing = fill3.Border(Listing(self._view))
log = fill3.Border(self._log, title="Log", log = fill3.Border(self._log, title="Log",
characters=Screen._DIMMED_BORDER) characters=Screen._DIMMED_BORDER)
@ -1041,12 +1000,13 @@ class Screen:
def _get_status_bar(self, width): def _get_status_bar(self, width):
incomplete = self._summary.result_total - self._summary.completed_total incomplete = self._summary.result_total - self._summary.completed_total
progress_bar_size = max(0, width * incomplete / progress_bar_size = width if self._summary.result_total == 0 else \
self._summary.result_total) max(0, width * incomplete / self._summary.result_total)
return self._get_status_bar_appearance(width, progress_bar_size) return self._get_status_bar_appearance(width, progress_bar_size)
def appearance(self, dimensions): def appearance(self, dimensions):
self._fix_listing() if len(self._summary._entries) > 0:
self._fix_listing()
if self._is_help_visible: if self._is_help_visible:
body = self._help_widget body = self._help_widget
elif self._is_fullscreen: elif self._is_fullscreen:
@ -1110,7 +1070,7 @@ def load_state(pickle_path, jobs_added_event, appearance_changed_event,
def on_filesystem_event(event, summary, root_path, appearance_changed_event): def on_filesystem_event(event, summary, root_path, appearance_changed_event):
path = fix_paths(root_path, [event.pathname])[0] path = list(fix_paths(root_path, [event.pathname]))[0]
if is_path_excluded(path[2:]): if is_path_excluded(path[2:]):
return return
inotify_actions = {pyinotify.IN_CREATE: summary.file_added, inotify_actions = {pyinotify.IN_CREATE: summary.file_added,
@ -1119,7 +1079,13 @@ def on_filesystem_event(event, summary, root_path, appearance_changed_event):
pyinotify.IN_MOVED_FROM: summary.file_deleted, pyinotify.IN_MOVED_FROM: summary.file_deleted,
pyinotify.IN_ATTRIB: summary.file_modified, pyinotify.IN_ATTRIB: summary.file_modified,
pyinotify.IN_CLOSE_WRITE: summary.file_modified} pyinotify.IN_CLOSE_WRITE: summary.file_modified}
inotify_actions[event.mask](path) if event.mask not in inotify_actions:
return
try:
inotify_actions[event.mask](path)
except Exception:
tools.log_error()
raise KeyboardInterrupt
appearance_changed_event.set() appearance_changed_event.set()
@ -1142,8 +1108,7 @@ def main(root_path, loop, worker_count=None, editor_command=None, theme=None,
log.delete_log_file() log.delete_log_file()
log.log_message("Program started.") log.log_message("Program started.")
jobs_added_event.set() jobs_added_event.set()
if not is_first_run: asyncio.ensure_future(summary.sync_with_filesystem(log))
summary.sync_with_filesystem(log)
callback = lambda event: on_filesystem_event(event, summary, root_path, callback = lambda event: on_filesystem_event(event, summary, root_path,
appearance_changed_event) appearance_changed_event)
notifier = setup_inotify(root_path, loop, callback, is_path_excluded) notifier = setup_inotify(root_path, loop, callback, is_path_excluded)
@ -1176,11 +1141,15 @@ def chdir(path):
def manage_cache(root_path): def manage_cache(root_path):
cache_path = os.path.join(root_path, tools.CACHE_PATH) cache_path = os.path.join(root_path, tools.CACHE_PATH)
timestamp_path = os.path.join(cache_path, "creation_time") timestamp_path = os.path.join(cache_path, "creation_time")
if os.path.exists(cache_path) and \ if os.path.exists(cache_path):
os.stat(__file__).st_mtime > os.stat(timestamp_path).st_mtime: timestamp = os.stat(timestamp_path).st_mtime
print("Eris has been updated, so clearing the cache and" for resource_path in ["__main__.py", "tools.py", "tools.toml"]:
" recalculating all results…") with importlib.resources.path(eris, resource_path) as resource:
shutil.rmtree(cache_path) if resource.stat().st_mtime > timestamp:
print("Eris has been updated, so clearing the cache and"
" recalculating all results…")
shutil.rmtree(cache_path)
break
if not os.path.exists(cache_path): if not os.path.exists(cache_path):
os.mkdir(cache_path) os.mkdir(cache_path)
open(timestamp_path, "w").close() open(timestamp_path, "w").close()

View file

@ -120,12 +120,11 @@ def join_vertical(appearances):
return result return result
class Column(collections.UserList): class Column:
def __init__(self, widgets, partition_func=even_partition, def __init__(self, widgets, partition_func=even_partition,
background_char=" "): background_char=" "):
collections.UserList.__init__(self, widgets) self.widgets = widgets
self.widgets = self.data
self.partition_func = partition_func self.partition_func = partition_func
self.background_char = background_char self.background_char = background_char

View file

@ -548,6 +548,7 @@ class Result:
self.pickle_path = os.path.join(CACHE_PATH, path + "-" + tool.__name__) self.pickle_path = os.path.join(CACHE_PATH, path + "-" + tool.__name__)
self.scroll_position = (0, 0) self.scroll_position = (0, 0)
self.status = Status.pending self.status = Status.pending
self.is_highlighted = False
@property @property
@lru_cache_with_eviction(maxsize=50) @lru_cache_with_eviction(maxsize=50)
@ -597,8 +598,16 @@ class Result:
def reset(self): def reset(self):
self.set_status(Status.pending) self.set_status(Status.pending)
def _get_cursor(self):
status_color = _STATUS_COLORS.get(self.status, None)
fg_color = (termstr.Color.white if self.status == Status.pending
else termstr.Color.black)
return termstr.TermStr("+", termstr.CharStyle(fg_color=fg_color,
bg_color=status_color))
def appearance_min(self): def appearance_min(self):
return [STATUS_TO_TERMSTR[self.status]] return ([self._get_cursor() if self.is_highlighted else
STATUS_TO_TERMSTR[self.status]])
def get_pages_dir(self): def get_pages_dir(self):
return self.pickle_path + ".pages" return self.pickle_path + ".pages"

View file

@ -13,7 +13,7 @@ if [ $DIST_ID != "ubuntu" ]; then
exit 1 exit 1
fi fi
echo "Installing the dependencies of the eris script…" echo "Installing the dependencies of the eris script…"
sudo apt --yes install python3-pip python3.7 util-linux sudo apt --yes install python3-pip python3.7 util-linux python3-sortedcontainers
python3.7 -m pip install pyinotify pygments docopt pillow toml python3.7 -m pip install pyinotify pygments docopt pillow toml
echo echo
echo "Installing all the tools eris may need…" echo "Installing all the tools eris may need…"

View file

@ -88,7 +88,7 @@ class SummaryCursorTest(unittest.TestCase):
self.original_method = __main__.Summary.sync_with_filesystem self.original_method = __main__.Summary.sync_with_filesystem
__main__.Summary.sync_with_filesystem = lambda foo: None __main__.Summary.sync_with_filesystem = lambda foo: None
self.summary = __main__.Summary(None, None) self.summary = __main__.Summary(None, None)
self.summary._column = [[1, 1, 1], [1, 1], [1, 1, 1]] self.summary._entries = [[1, 1, 1], [1, 1], [1, 1, 1]]
def tearDown(self): def tearDown(self):
__main__.Summary.sync_with_filesystem = self.original_method __main__.Summary.sync_with_filesystem = self.original_method
@ -124,59 +124,62 @@ class SummarySyncWithFilesystemTestCase(unittest.TestCase):
self.foo_path = os.path.join(self.temp_dir, "foo") self.foo_path = os.path.join(self.temp_dir, "foo")
self.bar_path = os.path.join(self.temp_dir, "bar.md") self.bar_path = os.path.join(self.temp_dir, "bar.md")
self.zoo_path = os.path.join(self.temp_dir, "zoo.html") self.zoo_path = os.path.join(self.temp_dir, "zoo.html")
_touch(self.foo_path)
_touch(self.bar_path)
self.jobs_added_event = asyncio.Event() self.jobs_added_event = asyncio.Event()
self.appearance_changed_event = asyncio.Event() self.appearance_changed_event = asyncio.Event()
self.summary = __main__.Summary(self.temp_dir, self.jobs_added_event) self.summary = __main__.Summary(self.temp_dir, self.jobs_added_event)
self.jobs_added_event.clear()
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()
callback = lambda event: __main__.on_filesystem_event( callback = lambda event: __main__.on_filesystem_event(
event, self.summary, self.temp_dir, self.appearance_changed_event) event, self.summary, self.temp_dir, self.appearance_changed_event)
__main__.setup_inotify(self.temp_dir, self.loop, callback, __main__.setup_inotify(self.temp_dir, self.loop, callback,
__main__.is_path_excluded) __main__.is_path_excluded)
_touch(self.foo_path)
_touch(self.bar_path)
self.log = __main__.Log(self.appearance_changed_event)
self.loop.run_until_complete(self.summary.sync_with_filesystem(self.log))
self.jobs_added_event.clear()
def tearDown(self): def tearDown(self):
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)
def _assert_paths(self, expected_paths): def _assert_paths(self, expected_paths):
actual_paths = [entry[0].path for entry in self.summary._column] actual_paths = [entry[0].path for entry in self.summary._entries]
self.assertEqual(set(actual_paths), set(expected_paths)) self.assertEqual(set(actual_paths), set(expected_paths))
def _assert_summary_invariants(self):
completed_total = 0
result_total = 0
for row in self.summary._entries:
for result in row:
if result.is_completed:
completed_total += 1
result_total += 1
self.assertEqual(self.summary.completed_total, completed_total)
self.assertEqual(self.summary.result_total, result_total)
max_width = max((len(row) for row in self.summary._entries), default=0)
self.assertEqual(self.summary._max_width, max_width)
max_path_length = max(
(len(row.path) - 2 for row in self.summary._entries), default=0)
self.assertEqual(self.summary._max_path_length, max_path_length)
def test_summary_initial_state(self): def test_summary_initial_state(self):
self._assert_summary_invariants()
self._assert_paths(["./bar.md", "./foo"]) self._assert_paths(["./bar.md", "./foo"])
self.assertFalse(self.jobs_added_event.is_set()) self.assertFalse(self.jobs_added_event.is_set())
def test_sync_removed_file(self): def test_sync_removed_file(self):
self._assert_paths(["./bar.md", "./foo"])
self.assertEqual(self.summary.result_total, 9)
self.assertEqual(self.summary.completed_total, 0)
self.assertEqual(self.summary._max_width, 5)
self.assertEqual(self.summary._max_path_length, len("bar.md"))
async def foo(): async def foo():
os.remove(self.bar_path) os.remove(self.bar_path)
self.loop.run_until_complete(foo()) self.loop.run_until_complete(foo())
self._assert_paths(["./foo"]) self._assert_paths(["./foo"])
self.assertEqual(self.summary.result_total, 4) self._assert_summary_invariants()
self.assertEqual(self.summary.completed_total, 0)
self.assertEqual(self.summary._max_width, 4)
self.assertEqual(self.summary._max_path_length, len("foo"))
self.assertFalse(self.jobs_added_event.is_set()) self.assertFalse(self.jobs_added_event.is_set())
def test_sync_added_file(self): def test_sync_added_file(self):
self._assert_paths(["./bar.md", "./foo"])
self.assertEqual(self.summary.result_total, 9)
self.assertEqual(self.summary.completed_total, 0)
self.assertEqual(self.summary._max_width, 5)
self.assertEqual(self.summary._max_path_length, 6)
async def foo(): async def foo():
_touch(self.zoo_path) _touch(self.zoo_path)
self.loop.run_until_complete(foo()) self.loop.run_until_complete(foo())
self._assert_paths(["./bar.md", "./foo", "./zoo.html"]) self._assert_paths(["./bar.md", "./foo", "./zoo.html"])
self.assertEqual(self.summary.result_total, 16) self._assert_summary_invariants()
self.assertEqual(self.summary.completed_total, 0)
self.assertEqual(self.summary._max_width, 7)
self.assertEqual(self.summary._max_path_length, len("zoo.html"))
self.assertTrue(self.jobs_added_event.is_set()) self.assertTrue(self.jobs_added_event.is_set())
# def test_sync_changed_file_metadata(self): # def test_sync_changed_file_metadata(self):
@ -201,12 +204,13 @@ class SummarySyncWithFilesystemTestCase(unittest.TestCase):
baz_path = os.path.join(self.temp_dir, "baz") baz_path = os.path.join(self.temp_dir, "baz")
os.symlink(self.foo_path, baz_path) os.symlink(self.foo_path, baz_path)
os.link(self.foo_path, self.zoo_path) os.link(self.foo_path, self.zoo_path)
self.summary.sync_with_filesystem() log = __main__.Log(self.appearance_changed_event)
self.loop.run_until_complete(self.summary.sync_with_filesystem(log))
self._assert_paths(["./bar.md", "./baz", "./foo", "./zoo.html"]) self._assert_paths(["./bar.md", "./baz", "./foo", "./zoo.html"])
self.assertTrue(id(self.summary._column[1]) != # baz self.assertTrue(id(self.summary._entries[1]) != # baz
id(self.summary._column[2])) # foo id(self.summary._entries[2])) # foo
self.assertTrue(id(self.summary._column[2]) != # foo self.assertTrue(id(self.summary._entries[2]) != # foo
id(self.summary._column[3])) # zoo id(self.summary._entries[3])) # zoo
self.assertTrue(self.jobs_added_event.is_set()) self.assertTrue(self.jobs_added_event.is_set())