diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 4fee13fc..e1beefe6 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -8,6 +8,14 @@ import borgmatic.actions.browse.paths def make_next_panel(focused_panel, option_id): + ''' + Given a focused panel widget and the selected option ID, return the next panel corresponding to + that selection. This is the mechanism by which the user can successively drill down from + configuration file to repository to archive to root directory to non-root directory or file. + + If the particular option ID on the focused panel doesn't have a supported next panel, then + return None. + ''' if isinstance(focused_panel, borgmatic.actions.browse.panels.Configuration_files_list): return borgmatic.actions.browse.panels.Repositories_list( config=focused_panel.configs[option_id] @@ -101,7 +109,7 @@ class Carousel(textual.containers.Horizontal): next_panel = make_next_panel(self.focused_panel, option_id) if next_panel is None: - self.notify('Cannot load this content', severity='warning') + self.notify('Cannot display this content', severity='warning') return self.panels.append(next_panel) @@ -125,7 +133,7 @@ class Carousel(textual.containers.Horizontal): An option has been selected, so advance to the next panel—unless the option selected is "..", in which case go to the previous panel. ''' - if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': + if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': # pragma: no cover return if event.option_id == '..': diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index aa4bd962..3e59de39 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -61,8 +61,6 @@ def log_to_widget(logs_widget): logger.setLevel(min(handler.level for handler in logger.handlers)) logger.addHandler(handler) - # Remove the console log handler so it doesn't try to log all over our UI; we have our own - # log handler for surfacing logs within the UI. with contextlib.suppress(StopIteration): console_handler = next( handler diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 4f8e9808..816a10ca 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -59,9 +59,10 @@ def add_archive_files( ) for path_type, file_path, link_target in file_type_paths: - pieces = (borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '❓'), file_path) + ( - ('→', link_target) if link_target else () - ) + pieces = ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '❓'), + file_path, + ) + (('→', link_target) if link_target else ()) highlighted_option = directory_list.highlighted_option sorted_options = sorted( [ @@ -91,7 +92,9 @@ def load_file_preview(browse_app, file_preview, config, repository, archive_name browse_app.call_from_thread(file_preview.clear) if file_content is None: - browse_app.call_from_thread(file_preview.write, 'Cannot load a preview for this file') + browse_app.call_from_thread(file_preview.write, 'Cannot display a preview for this file') else: syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) - browse_app.call_from_thread(file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer)) + browse_app.call_from_thread( + file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer) + ) diff --git a/pyproject.toml b/pyproject.toml index a2d76607..62e6a48d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ namespaces = false [tool.pytest.ini_options] testpaths = "tests" -addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120" +addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120 --asyncio-mode=auto" [tool.ruff] line-length = 100 diff --git a/test_requirements.in b/test_requirements.in index 8db5916e..dc8ed6c7 100644 --- a/test_requirements.in +++ b/test_requirements.in @@ -27,5 +27,6 @@ requests-oauthlib rich rpds-py ruamel-yaml>0.15.0 +textual typing-extensions urllib3 diff --git a/test_requirements.txt b/test_requirements.txt index 4d73938c..3aa2cd76 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -13,13 +13,16 @@ idna==3.16 # via requests, -r test_requirements.in iniconfig==2.3.0 # via pytest, -r test_requirements.in jsonschema==4.26.0 # via -r test_requirements.in jsonschema-specifications==2025.9.1 # via jsonschema, -r test_requirements.in +linkify-it-py==2.1.0 # via markdown-it-py markdown==3.10.2 # via apprise, -r test_requirements.in -markdown-it-py==4.2.0 # via rich +markdown-it-py==4.2.0 # via mdit-py-plugins, rich, textual +mdit-py-plugins==0.6.1 # via textual mdurl==0.1.2 # via markdown-it-py oauthlib==3.3.1 # via requests-oauthlib, -r test_requirements.in packaging==26.2 # via pytest, -r test_requirements.in +platformdirs==4.9.6 # via textual pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in -pygments==2.20.0 # via pytest, rich, -r test_requirements.in +pygments==2.20.0 # via pytest, rich, textual, -r test_requirements.in pytest==9.0.3 # via pytest-asyncio, pytest-cov, pytest-timeout, -r test_requirements.in pytest-asyncio==1.3.0 # via -r test_requirements.in pytest-cov==7.1.0 # via -r test_requirements.in @@ -28,8 +31,10 @@ pyyaml>5.0.0 referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in requests==2.34.2 # via apprise, requests-oauthlib, -r test_requirements.in requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in -rich==15.0.0 # via -r test_requirements.in +rich==15.0.0 # via textual, -r test_requirements.in rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in ruamel-yaml>0.15.0 -typing-extensions==4.15.0 # via -r test_requirements.in +textual==8.2.7 # via -r test_requirements.in +typing-extensions==4.15.0 # via textual, -r test_requirements.in +uc-micro-py==2.0.0 # via linkify-it-py urllib3==2.7.0 # via requests, -r test_requirements.in diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py index 4d309e2b..02b79421 100644 --- a/tests/integration/actions/browse/test_app.py +++ b/tests/integration/actions/browse/test_app.py @@ -1,5 +1,3 @@ -import asyncio - import borgmatic.actions.browse.app import borgmatic.actions.browse.panels @@ -7,10 +5,6 @@ import pytest from flexmock import flexmock -pytestmark = pytest.mark.asyncio(loop_scope='module') -loop: asyncio.AbstractEventLoop - - async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): app = borgmatic.actions.browse.app.Browse_app( configs={ diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 0ce64dbe..90ff3e6a 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -1,19 +1,182 @@ -import asyncio - import borgmatic.actions.browse.app import borgmatic.actions.browse.carousel +import borgmatic.actions.browse.loading import borgmatic.actions.browse.logs import borgmatic.actions.browse.panels import borgmatic.actions.browse.workers import pytest +import pytest_asyncio from flexmock import flexmock import textual.widgets.option_list +from borgmatic.actions.browse import carousel as module -pytestmark = pytest.mark.asyncio(loop_scope='module') -loop: asyncio.AbstractEventLoop +def test_make_next_panel_with_configuration_files_list_returns_repositories_list(): + configs = {'test.yaml': {'repositories': [{'path': 'test.borg'}]}} + + repositories_list = module.make_next_panel( + focused_panel=borgmatic.actions.browse.panels.Configuration_files_list(configs), + option_id='test.yaml', + ) + + assert isinstance(repositories_list, borgmatic.actions.browse.panels.Repositories_list) + assert repositories_list.config == configs['test.yaml'] + + +def test_make_next_panel_with_repositories_list_returns_archives_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(borgmatic.actions.browse.panels.Archives_list).should_receive('app').and_return( + flexmock() + ) + + archives_list = module.make_next_panel( + focused_panel=borgmatic.actions.browse.panels.Repositories_list(config), + option_id=0, + ) + + assert isinstance(archives_list, borgmatic.actions.browse.panels.Archives_list) + assert archives_list.config == config + assert archives_list.repository == config['repositories'][0] + + +def test_make_next_panel_with_archives_list_returns_directory_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Archives_list).should_receive('app').and_return( + flexmock() + ) + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + + directory_list = module.make_next_panel( + focused_panel=borgmatic.actions.browse.panels.Archives_list( + config, config['repositories'][0] + ), + option_id='archive', + ) + + assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + + +def test_make_next_panel_with_root_directory_list_and_selected_directory_option_returns_new_directory_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive' + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📁 etc')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='etc') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.path_components == ('etc',) + + +def test_make_next_panel_with_non_root_directory_list_and_selected_directory_option_returns_new_directory_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive', path_components=('etc',), + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📁 borgmatic')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='borgmatic') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.path_components == ('etc', 'borgmatic') + + +def test_make_next_panel_with_root_directory_list_and_selected_file_option_returns_new_file_preview(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.workers).should_receive('load_file_preview') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + flexmock(borgmatic.actions.browse.panels.File_preview).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive' + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📄 config.yaml')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.File_preview) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.file_path == 'config.yaml' + + +def test_make_next_panel_with_non_root_directory_list_and_selected_file_option_returns_new_file_preview(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.workers).should_receive('load_file_preview') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + flexmock(borgmatic.actions.browse.panels.File_preview).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive', path_components=('etc', 'borgmatic'), + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📄 config.yaml')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.File_preview) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.file_path == 'etc/borgmatic/config.yaml' + + +def test_make_next_panel_with_unsupported_focused_panel_returns_none(): + assert module.make_next_panel(focused_panel=flexmock(), option_id='hmmm') is None + + +@pytest.mark.parametrize('icon', ('🔗', '🚰', '🐙')) +def test_make_next_panel_with_directory_list_and_unsupported_selected_option_returns_none(icon): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive' + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt=f'{icon} config.yaml')) + + assert module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') is None async def test_carousel_previous_action_with_multiple_configs_does_not_raise(): @@ -41,7 +204,7 @@ async def test_carousel_previous_action_with_one_config_does_not_raise(): await pilot.press('left') -async def test_carousel_next_action_with_multiple_configs_advances_panels(): +async def test_carousel_next_action_with_multiple_configs_advances_panel(): app = borgmatic.actions.browse.app.Browse_app( configs={ 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, @@ -67,7 +230,7 @@ async def test_carousel_next_action_with_multiple_configs_advances_panels(): assert app.focused == carousel.panels[1] -async def test_carousel_next_action_with_one_config_advances_to_next_panel(): +async def test_carousel_next_action_with_one_config_advances_panel(): app = borgmatic.actions.browse.app.Browse_app( configs={ 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, @@ -91,6 +254,27 @@ async def test_carousel_next_action_with_one_config_advances_to_next_panel(): assert app.focused == carousel.panels[1] +async def test_carousel_next_action_with_no_next_panel_does_not_advance(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module).should_receive('make_next_panel') + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert carousel.panels[0].styles.display == 'block' + assert app.focused == carousel.panels[0] + + async def test_carousel_next_action_and_previous_action_returns_to_original_panel(): app = borgmatic.actions.browse.app.Browse_app( configs={