diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy index 58965a910e..44f61b8b7d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy @@ -416,6 +416,18 @@ class AnsiLogObserver implements TraceObserverV2 { private final static Pattern TAG_REGEX = ~/ \((.+)\)( *)$/ private final static Pattern LBL_REPLACE = ~/ \(.+\) *$/ + // OSC 8 hyperlink escape sequences (using BEL as String Terminator) + private final static String HYPERLINK_START = '\033]8;;' + private final static String HYPERLINK_SEP = '\007' + private final static String HYPERLINK_END = '\033]8;;\007' + + protected static String hyperlink(String text, String url) { + if( !url ) + return text + final href = url.startsWith('/') ? 'file://' + url : url + return HYPERLINK_START + href + HYPERLINK_SEP + text + HYPERLINK_END + } + protected Ansi line(ProgressRecord stats) { final term = ansi() final float tot = stats.getTotalCount() @@ -443,8 +455,10 @@ class AnsiLogObserver implements TraceObserverV2 { final numbs = " ${(int)com} of ${(int)tot}".toString() // Task hash, eg: [fa/71091a] + // make clickable hyperlink to work dir inferred from session workDir and task hash + final hashDisplay = (stats.workDir && !session.config.cleanup) ? hyperlink(hh, stats.workDir) : hh term.a(Attribute.INTENSITY_FAINT).a('[').reset() - term.fg(Color.BLUE).a(hh).reset() + term.fg(Color.BLUE).a(hashDisplay).reset() term.a(Attribute.INTENSITY_FAINT).a('] ').reset() // Only show 'process > ' if the terminal has lots of width diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy index a0f1e71caa..4f115c0457 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy @@ -32,6 +32,7 @@ class ProgressRecord implements Cloneable { final int index final String name // process name String hash // current task hash + String workDir // current task work directory URI String taskName // current task name int pending // number of new tasks ready to be submitted int submitted // number of tasks submitted for execution not yet started diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy index 1ebffe4659..900e5ce234 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy @@ -301,6 +301,7 @@ class WorkflowStats implements Cloneable { void markSubmitted(TaskRun task) { final state = getOrCreateRecord(task.processor) state.hash = task.hashLog + state.workDir = task.workDirStr state.taskName = task.name state.pending -- state.submitted ++ @@ -350,6 +351,7 @@ class WorkflowStats implements Cloneable { ProgressRecord state = getOrCreateRecord(task.processor) state.taskName = task.name state.hash = task.hashLog + state.workDir = task.workDirStr if( status == TaskStatus.SUBMITTED ) { state.submitted -- @@ -401,6 +403,7 @@ class WorkflowStats implements Cloneable { if( trace ) { state.cached++ state.hash = task.hashLog + state.workDir = task.workDirStr state.taskName = task.name // global counters this.cachedMillis += getCpuTime(trace) @@ -409,6 +412,7 @@ class WorkflowStats implements Cloneable { else { state.stored++ state.hash = 'skipped' + state.workDir = null state.taskName = task.name } changeTimestamp = System.currentTimeMillis() diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy index 46c470a234..53b8c5ad6f 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy @@ -16,6 +16,7 @@ package nextflow.trace +import nextflow.Session import spock.lang.Specification import spock.lang.Unroll @@ -108,4 +109,63 @@ class AnsiLogObserverTest extends Specification { } + def 'should create hyperlink' () { + expect: + // Local paths (starting with /) get file:// prefix added automatically + AnsiLogObserver.hyperlink('hash', '/path/to/work') == '\033]8;;file:///path/to/work\007hash\033]8;;\007' + // URLs with schemes are used as-is + AnsiLogObserver.hyperlink('hash', 's3://bucket/path') == '\033]8;;s3://bucket/path\007hash\033]8;;\007' + AnsiLogObserver.hyperlink('hash', 'gs://bucket/path') == '\033]8;;gs://bucket/path\007hash\033]8;;\007' + AnsiLogObserver.hyperlink('hash', 'az://container/path') == '\033]8;;az://container/path\007hash\033]8;;\007' + AnsiLogObserver.hyperlink('text', null) == 'text' + AnsiLogObserver.hyperlink('text', '') == 'text' + } + + def 'should render hash as hyperlink when workDir is set' () { + given: + def session = Mock(Session) { getConfig() >> [cleanup: false] } + def observer = new AnsiLogObserver() + observer.@session = session + observer.@labelWidth = 3 + observer.@cols = 190 + and: + def stats = new ProgressRecord(1, 'foo') + stats.submitted = 1 + stats.hash = '4e/486876' + stats.workDir = WORKDIR + + when: + def result = observer.line(stats).toString() + + then: + result.contains('\033]8;;' + EXPECTED_HREF + '\007') + result.contains('\033]8;;\007') + + where: + WORKDIR | EXPECTED_HREF + '/work/4e/486876abc' | 'file:///work/4e/486876abc' + 's3://bucket/work/4e/486876abc' | 's3://bucket/work/4e/486876abc' + } + + def 'should not render hyperlink when cleanup is enabled' () { + given: + def session = Mock(Session) { getConfig() >> [cleanup: true] } + def observer = new AnsiLogObserver() + observer.@session = session + observer.@labelWidth = 3 + observer.@cols = 190 + and: + def stats = new ProgressRecord(1, 'foo') + stats.submitted = 1 + stats.hash = '4e/486876' + stats.workDir = '/work/4e/486876abc' + + when: + def result = observer.line(stats).toString() + + then: + // Should NOT contain hyperlink start sequence + !result.contains('\033]8;;') + } + } diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy index c3aecb26dc..9b8e641dc7 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy @@ -31,6 +31,8 @@ class ProgressRecordTest extends Specification { with(rec) { index == 10 name == 'foo' + hash == null + workDir == null pending == 0 submitted == 0 running == 0 diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy index a1bc2e4b9d..9b7e5a147e 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy @@ -222,9 +222,11 @@ class WorkflowStatsTest extends Specification { def PENDING = 10 def SUBMITTED = 20 def HASH = 'xyz' + def WORKDIR = 'file:///work/xy/z123456' and: def task = Mock(TaskRun) { getHashLog() >> HASH + getWorkDirStr() >> WORKDIR getProcessor() >> Mock(TaskProcessor) { getId() >> 0 } } and: @@ -247,6 +249,7 @@ class WorkflowStatsTest extends Specification { rec.submitted == SUBMITTED +1 and: rec.hash == HASH + rec.workDir == WORKDIR } def 'should mark running' () { @@ -724,9 +727,11 @@ class WorkflowStatsTest extends Specification { given: def CACHED = 10 def STORED = 20 + def WORKDIR = 'file:///work/xy/z123456' and: def task = Mock(TaskRun) { getHashLog() >> 'XYZ' + getWorkDirStr() >> WORKDIR getProcessor() >> Mock(TaskProcessor) { getId() >> 0 } } and: @@ -749,6 +754,7 @@ class WorkflowStatsTest extends Specification { stats.cachedDuration == 5.sec and: rec.hash == 'XYZ' + rec.workDir == WORKDIR rec.cached == CACHED +1 rec.stored == STORED } @@ -760,6 +766,7 @@ class WorkflowStatsTest extends Specification { and: def task = Mock(TaskRun) { getHashLog() >> 'XYZ' + getWorkDirStr() >> 'file:///work/xy/z123456' getProcessor() >> Mock(TaskProcessor) { getId() >> 0 } } and: @@ -781,6 +788,7 @@ class WorkflowStatsTest extends Specification { rec.stored == STORED +1 and: rec.hash == 'skipped' + rec.workDir == null } diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy index 1dd6547f1e..510cacb36a 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy @@ -113,6 +113,7 @@ class TowerJsonGeneratorTest extends Specification { progress.get(0) == [ index:1, name: 'foo', + workDir: null, pending: 0, submitted: 0, running: 0, @@ -133,6 +134,7 @@ class TowerJsonGeneratorTest extends Specification { progress[1] == [ index:2, name: 'bar', + workDir: null, pending: 1, submitted: 2, running: 3,