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',
+ )