diff --git a/changes.d/6611.feat.md b/changes.d/6611.feat.md new file mode 100644 index 0000000000..21e68d1f16 --- /dev/null +++ b/changes.d/6611.feat.md @@ -0,0 +1 @@ +Tui: Add ability to open log files in external tools. Configure your `$EDITOR`, `$GEDITOR` or `$PAGER` options to configure which tool is used. diff --git a/cylc/flow/tui/overlay.py b/cylc/flow/tui/overlay.py index fc89399047..a2fb85f3ee 100644 --- a/cylc/flow/tui/overlay.py +++ b/cylc/flow/tui/overlay.py @@ -38,8 +38,14 @@ """ from functools import partial +import os import re +import shlex +import stat +from subprocess import Popen import sys +import tempfile +from time import sleep import urwid @@ -473,6 +479,56 @@ def open_log(*_, filename=None, close=False): if close: app.close_topmost() + def open_in_editor(*_, command): + """Suspend Tui, open the file in an external utility, then restore Tui. + + Args: + command: + The command to run as a list, e.g. 'gvim -f'. + This command must be blocking, the tui session will be + restored when the command exits. + + """ + nonlocal text_widget + + with tempfile.NamedTemporaryFile('w+') as temp_file: + # write the text into a temp file + temp_file.write(text_widget.text) + temp_file.seek(0, 0) + + # make the file readonly to avoid confusion + os.chmod(temp_file.name, stat.S_IRUSR) + + # suspend Tui + app.loop.screen.stop() + + # open the file using the external tool (must be blocking) + print( + 'Launching external tool, Tui will resume once it exits.', + file=sys.stderr, + ) + try: + Popen( + [*shlex.split(command), temp_file.name] + ).wait() # nosec B603 + # (this is running a command the user has configured) + except OSError as exc: + # ensure any critical errors are visible to the user so + # that they have a chance to fix them + _sleep_time = 5 + print( + ( + f'Error running {command} {temp_file.name}' + f'\n{exc}' + f'\nTui will resume in {_sleep_time} seconds' + ), + file=sys.stderr + ) + sleep(_sleep_time) + + # restore Tui + app.loop.screen.start() + # load the default log file if id_: # NOTE: the kwargs are not provided in the overlay unit tests @@ -489,6 +545,25 @@ def open_log(*_, filename=None, close=False): 'Select File', on_press=open_menu, ), + urwid.Columns([ + ('pack', urwid.Text('Open in: ')), + *( + ( + 'pack', + urwid.Button( + label, + align='left', + on_press=partial(open_in_editor, command=command), + ), + ) + for label, command in ( + ('$EDITOR', os.environ.get('EDITOR', 'vim')), + ('$GEDITOR', os.environ.get('GEDITOR', 'gvim -f')), + ('$PAGER', os.environ.get('PAGER', 'less')), + ('vim', 'vim'), + ) + ), + ]), urwid.Divider(), text_widget, ]), diff --git a/tests/integration/tui/screenshots/test_errors.list-error.html b/tests/integration/tui/screenshots/test_errors.list-error.html index 02448aa026..a7045e7b54 100644 --- a/tests/integration/tui/screenshots/test_errors.list-error.html +++ b/tests/integration/tui/screenshots/test_errors.list-error.html @@ -2,7 +2,7 @@ Error: Somethi Error < Select File Something went wrong :( > - + Open in: < $E diff --git a/tests/integration/tui/screenshots/test_errors.open-error.html b/tests/integration/tui/screenshots/test_errors.open-error.html index 31d842ca75..a0fd83fc10 100644 --- a/tests/integration/tui/screenshots/test_errors.open-error.html +++ b/tests/integration/tui/screenshots/test_errors.open-error.html @@ -2,7 +2,7 @@ Error: Something went wrong :( < Select File > - + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > diff --git a/tests/integration/tui/screenshots/test_job_logs.01-job.out.html b/tests/integration/tui/screenshots/test_job_logs.01-job.out.html index 6f6f87fdce..ec2815d87f 100644 --- a/tests/integration/tui/screenshots/test_job_logs.01-job.out.html +++ b/tests/integration/tui/screenshots/test_job_logs.01-job.out.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > job: 1/a/01 this is a job log @@ -24,7 +25,6 @@ - q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/screenshots/test_job_logs.02-job.out.html b/tests/integration/tui/screenshots/test_job_logs.02-job.out.html index e14a77b99e..ae89e32ef5 100644 --- a/tests/integration/tui/screenshots/test_job_logs.02-job.out.html +++ b/tests/integration/tui/screenshots/test_job_logs.02-job.out.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > job: 1/a/02 this is a job log @@ -24,7 +25,6 @@ - q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html b/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html index c82cd53b00..0c26a89776 100644 --- a/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html +++ b/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > this is the scheduler log file line 2 @@ -10,21 +11,20 @@ line 5 line 6 line 7 - line 8 - line 9 ────────────────────────────────────── - line 10 Select File - line 11 - line 12 < config/01-start-01.cylc > - line 13 < config/flow-processed.cylc > - line 14 < scheduler/01-start-01.log > - line 15 - line 16 q to close - line 17 ────────────────────────────────────── + line 8 ────────────────────────────────────── + line 9 Select File + line 10 + line 11 < config/01-start-01.cylc > + line 12 < config/flow-processed.cylc > + line 13 < scheduler/01-start-01.log > + line 14 + line 15 q to close + line 16 ────────────────────────────────────── + line 17 line 18 line 19 line 20 line 21 - line 22 q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html b/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html index c9e3256e55..a180fabd8a 100644 --- a/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html +++ b/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > this is the scheduler log file line 2 @@ -24,7 +25,6 @@ line 19 line 20 line 21 - line 22 q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html b/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html index c7ab1e925e..3e744a383b 100644 --- a/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html +++ b/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > [scheduling] [[graph]] @@ -24,7 +25,6 @@ - q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html b/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html index 481d427be6..4fdf42f9c3 100644 --- a/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html +++ b/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > job: 1/a/02 this is a job error @@ -24,7 +25,6 @@ - q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html b/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html index e14a77b99e..ae89e32ef5 100644 --- a/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html +++ b/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html @@ -2,6 +2,7 @@ Host: myhost Path: mypath < Select File > + Open in: < $EDITOR >< $GEDITOR >< $PAGER >< vim > job: 1/a/02 this is a job log @@ -24,7 +25,6 @@ - q to close ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/tui/test_logs.py b/tests/integration/tui/test_logs.py index 189935e038..17ae3554a7 100644 --- a/tests/integration/tui/test_logs.py +++ b/tests/integration/tui/test_logs.py @@ -379,3 +379,111 @@ def cli_cmd_fail(*args, **kwargs): 'list-error', 'the error message should be displayed in a pop up', ) + + +async def test_external_editor( + workflow, + mod_rakiura, + wait_log_loaded, + monkeypatch, + capsys, +): + """Test the "open in external editor" functionality. + + This test covers the relevant code about as well as we can in an + integration test. + + * The integration tests write HTML fragments to a file rather ANSI to a + terminal. + * Suspending / restoring the Tui session involves shell interaction that + we cannot simulate here. + * We're also not testing subprocesses in this integration test. + + But this test passing tells us that the relevant code does indeed run + without falling over in a heap, so it will detect interface breakages and + the like which is useful. + """ + fake_popen_instances = [] + + class FakePopen: + def __init__(self, cmd, *args, raises=None, **kwargs): + nonlocal fake_popen_instances + fake_popen_instances.append(self) + self.cmd = cmd + self.args = args + self.kwargs = kwargs + self.raises = raises + + def wait(self): + if self.raises: + raise self.raises() + return 0 + + # mock out subprocess.Popen + monkeypatch.setattr( + 'cylc.flow.tui.overlay.Popen', + FakePopen, + ) + # mock out time.sleep + monkeypatch.setattr( + 'cylc.flow.tui.overlay.sleep', + lambda x: None, + ) + + with mod_rakiura(size='80,30') as rk: + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # open the log view on scheduler + rk.user_input('down', 'enter', 'down', 'down', 'enter') + + # it will fail to open + await wait_log_loaded() + + assert len(fake_popen_instances) == 0 + assert capsys.readouterr()[1] == '' + + # select the open in "$EDITOR" option + rk.user_input('down', 'left', 'left', 'left') + + # make a note of what the screen looks like + rk.compare_screenshot( + 'before-opening-editor', + 'The open in $EDITOR option should be selected', + ) + + # launch the external tool + rk.user_input('enter') + + # the subprocess should be started and a message logged to stderr + assert len(fake_popen_instances) == 1 + assert 'launching external tool' in capsys.readouterr()[1].lower() + + # once the subprocess exist, the Tui session should be restored + # exactly as it was before + rk.compare_screenshot( + 'before-opening-editor', + 'The Tui session should restore exactly as it was before', + ) + + # get the subprocess to fail in a nasty way + from functools import partial + monkeypatch.setattr( + 'cylc.flow.tui.overlay.Popen', + partial(FakePopen, raises=OSError), + ) + + # launch the external tool + rk.user_input('enter') + + # the subprocess should be started, the error should be logged + # to stderr + assert len(fake_popen_instances) == 2 + assert 'error running' in capsys.readouterr()[1].lower() + + # once the subprocess exist, the Tui session should be restored + # exactly as it was before + rk.compare_screenshot( + 'before-opening-editor', + 'The Tui session should restore exactly as it was before', + )