diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index 0e8f8159..8d414068 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -10,9 +10,18 @@ import borgmatic.actions.browse.workers class Archives_list(textual.widgets.OptionList): + ''' + A widget for selecting a single Borg archive from among the archives in a repository. The item + selection event is handled in a Carousel instance, the parent widget of an Archives_list. + ''' + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, config, repository): + ''' + Given a configuration dict and a repository dict, start loading the archives from the + repository for eventual display in this widget. + ''' self.config = config self.repository = repository @@ -31,5 +40,10 @@ class Archives_list(textual.widgets.OptionList): ) def on_option_list_option_highlighted(self, event): + ''' + When the highlighted option changes, record that fact. This flag is consumed in + borgmatic.actions.browse.workers.add_repository_archives() in order to retain the + highlighted option even as other options load around it. + ''' if self.highlighted not in {None, 0}: self.highlighted_option_changed = True diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 3e94c2cc..cfa54b89 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -14,11 +14,14 @@ import borgmatic.actions.browse.workers def get_relative_archive_path_components(archive_path, current_directory_path_components): ''' Given an Archive_path instance and a tuple of path components for the currently browsed - directory, get the path components for the archive path relative to that directory. + directory, get the path components as a tuple for the archive path relative to that directory. - If the archive path is not actually relative to that directory, return None. + For instance, given an archive path with a path of 'foo/bar/baz/quux.txt' and current + directory path components of ('foo', 'bar'), return ('baz', 'quux.txt'). + + If the archive path is not actually relative to the current directory, return None. ''' - archive_path_components = archive_path.file_path.split(os.path.sep) + archive_path_components = tuple(archive_path.file_path.split(os.path.sep)) if not current_directory_path_components: return archive_path_components @@ -35,15 +38,20 @@ def get_relative_archive_path_components(archive_path, current_directory_path_co return archive_path_components[len(current_directory_path_components) :] -def make_directory_list_option(archive_path, archive_path_components): +def make_directory_list_option(archive_path, relative_path_components): + ''' + Given an Archive_path instance and a tuple of relative path components for it, make a + textual.widgets.option_list.Option for the path. Use an the icon based on whether this looks + like a terminal filename or a directory. + ''' pieces = ( borgmatic.actions.browse.icons.PATH_TYPE_ICONS.get( - archive_path.path_type if len(archive_path_components) == 1 else 'd', '❓' + archive_path.path_type if len(relative_path_components) == 1 else 'd', '❓' ), - archive_path_components[0], + relative_path_components[0], ) + (('→', archive_path.link_target) if archive_path.link_target else ()) - return textual.widgets.option_list.Option(' '.join(pieces), id=archive_path_components[0]) + return textual.widgets.option_list.Option(prompt=' '.join(pieces), id=relative_path_components[0]) def add_archive_paths( @@ -60,16 +68,16 @@ def add_archive_paths( ( *directory_list.options, *( - make_directory_list_option(archive_path, archive_path_components) + make_directory_list_option(archive_path, relative_path_components) for archive_path in archive_paths - for archive_path_components in ( + for relative_path_components in ( get_relative_archive_path_components( archive_path, directory_list.path_components, ), ) - if archive_path_components - if not archive_path_components[0] in directory_list._id_to_option + if relative_path_components + if not relative_path_components[0] in directory_list._id_to_option ), ), key=lambda option: ((option.id == 'loading-indicator'), option.prompt), diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index b653a4cc..1f34cc48 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -4,13 +4,15 @@ import os import textual.binding import textual.widgets -import borgmatic.actions.browse.archive import borgmatic.actions.browse.loading -import borgmatic.actions.browse.icons import borgmatic.actions.browse.workers class File_preview(textual.widgets.RichLog): + ''' + A widget for extracting and previewing the contents of a file stored in a Borg archive. + ''' + BINDINGS = [ *textual.widgets.RichLog.BINDINGS, textual.binding.Binding( @@ -28,21 +30,17 @@ class File_preview(textual.widgets.RichLog): ] def __init__(self, config, repository, archive_name, file_path): + ''' + Given a configuration dict, a repository dict, an archive name, and the path of a file in + the archive, start loading the file's contents for eventual display in this widget. + ''' self.config = config self.repository = repository self.archive_name = archive_name self.file_path = file_path super().__init__(classes='panel') - self.border_title = ' '.join( - ( - borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ - borgmatic.actions.browse.archive.Path_type.FILE.value - ], - self.file_path, - 'preview', - ) - ) + self.border_title = ' '.join(('📄', self.file_path, 'preview')) self.auto_scroll = False timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) diff --git a/borgmatic/actions/browse/repositories_list.py b/borgmatic/actions/browse/repositories_list.py index 8d01e466..8985c78f 100644 --- a/borgmatic/actions/browse/repositories_list.py +++ b/borgmatic/actions/browse/repositories_list.py @@ -10,9 +10,18 @@ import borgmatic.actions.browse.workers class Repositories_list(textual.widgets.OptionList): + ''' + A widget for selecting a single Borg repository from among the repositories in a borgmatic + configuratin file. The item selection event is handled in a Carousel instance, the parent widget + of a Repositories_list. + ''' + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, config): + ''' + Given a configuration dict, populate the repositories in this widget. + ''' self.config = config self.repositories = config['repositories'] diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index bdbd100e..e7697722 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -34,6 +34,8 @@ def add_repository_archives(browse_app, archives_list, config, repository, timer loading_option, ), ) + + # Retain the highlighted option position even as other options load around it. archives_list.highlighted = ( archives_list.get_option_index(highlighted_option.id) if highlighted_option and archives_list.highlighted_option_changed diff --git a/tests/integration/actions/browse/test_archives_list.py b/tests/integration/actions/browse/test_archives_list.py new file mode 100644 index 00000000..5bf205e6 --- /dev/null +++ b/tests/integration/actions/browse/test_archives_list.py @@ -0,0 +1,63 @@ +from borgmatic.actions.browse import archives_list as module + +from flexmock import flexmock +import textual.widgets.option_list + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_none_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.highlighted = None + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is False + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.highlighted = 0 + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is False + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) + archives_list.highlighted = 0 + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is False + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_non_zero_marks_it_changed(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) + archives_list.add_option(textual.widgets.option_list.Option('one', id='one')) + archives_list.highlighted = 1 + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is True diff --git a/tests/integration/actions/browse/test_file_preview.py b/tests/integration/actions/browse/test_file_preview.py new file mode 100644 index 00000000..6ef8cf7c --- /dev/null +++ b/tests/integration/actions/browse/test_file_preview.py @@ -0,0 +1,14 @@ +from borgmatic.actions.browse import file_preview as module + +from flexmock import flexmock +import textual.widgets.option_list + + +def test_file_preview_does_not_raise(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_file_preview') + flexmock(module.borgmatic.actions.browse.repositories_list.Repositories_list).should_receive( + 'app' + ).and_return(flexmock()) + + module.Repositories_list(config=flexmock()) diff --git a/tests/integration/actions/browse/test_repositories_list.py b/tests/integration/actions/browse/test_repositories_list.py new file mode 100644 index 00000000..b0916554 --- /dev/null +++ b/tests/integration/actions/browse/test_repositories_list.py @@ -0,0 +1,14 @@ +from borgmatic.actions.browse import repositories_list as module + +from flexmock import flexmock + + +def test_repositories_list_populates_options(): + config = {'repositories': [{'path': 'test1.borg'}, {'path': 'test2.borg', 'label': 'two'}]} + + repositories_list = module.Repositories_list(config=config) + assert len(repositories_list.options) == 2 + assert repositories_list.options[0].prompt == 'test1.borg' + assert repositories_list.options[0].id == 0 + assert repositories_list.options[1].prompt == 'two' + assert repositories_list.options[1].id == 1 diff --git a/tests/unit/actions/browse/test_directories_list.py b/tests/unit/actions/browse/test_directories_list.py new file mode 100644 index 00000000..3ee21c38 --- /dev/null +++ b/tests/unit/actions/browse/test_directories_list.py @@ -0,0 +1,90 @@ +from borgmatic.actions.browse import directory_list as module + +from flexmock import flexmock + + +def test_get_relative_archive_path_components_strips_off_current_directory(): + assert module.get_relative_archive_path_components( + flexmock(file_path='foo/bar/baz/quux.txt'), ('foo', 'bar') + ) == ('baz', 'quux.txt') + + +def test_get_relative_archive_path_components_with_root_current_directory_strips_off_nothing(): + assert module.get_relative_archive_path_components( + flexmock(file_path='foo/bar/baz/quux.txt'), () + ) == ('foo', 'bar', 'baz', 'quux.txt') + + +def test_get_relative_archive_path_components_with_non_matching_paths_returns_none(): + assert ( + module.get_relative_archive_path_components( + flexmock(file_path='foo/bar/baz/quux.txt'), ('etc',) + ) + is None + ) + + +def test_make_directory_list_option_with_file_path_makes_file_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='-', file_path='foo/bar/baz.txt', link_target=''), ('baz.txt',) + ) + + assert option.prompt == '📄 baz.txt' + assert option.id == 'baz.txt' + + +def test_make_directory_list_option_with_directory_path_makes_directory_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='d', file_path='foo/bar/baz', link_target=''), ('baz',) + ) + + assert option.prompt == '📁 baz' + assert option.id == 'baz' + + +def test_make_directory_list_option_with_contained_file_path_makes_directory_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='d', file_path='foo/bar/baz.txt', link_target=''), ('bar', 'baz.txt',) + ) + + assert option.prompt == '📁 bar' + assert option.id == 'bar' + + +def test_make_directory_list_option_with_link_path_makes_link_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='l', file_path='foo/bar/baz.txt', link_target='quux.txt'), ('baz.txt',) + ) + + assert option.prompt == '🔗 baz.txt → quux.txt' + assert option.id == 'baz.txt' + + +def test_make_directory_list_option_with_pipe_path_makes_pipe_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='p', file_path='foo/bar/baz.txt', link_target=''), ('baz.txt',) + ) + + assert option.prompt == '🚰 baz.txt' + assert option.id == 'baz.txt' + + +def test_make_directory_list_option_with_unknown_path_makes_unknown_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='wtf', file_path='foo/bar/baz.txt', link_target=''), ('baz.txt',) + ) + + assert option.prompt == '❓ baz.txt' + assert option.id == 'baz.txt' diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 69b84168..5d3f129a 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -2132,7 +2132,6 @@ def test_collect_highlander_action_summary_logs_error_on_run_browse_failure(): assert {log.levelno for log in logs} == {logging.CRITICAL} - def test_collect_configuration_run_summary_logs_info_for_success(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module.command).should_receive('filter_hooks').with_args(