Skip to content

Commit 66e9ea4

Browse files
committed
Add clickable hyperlinks to task hash in ANSI progress display
Make the task hash (e.g. [4e/486876]) a clickable hyperlink that opens the task work directory. Uses OSC 8 terminal escape sequences which are supported by modern terminals like iTerm2, Windows Terminal, and others. The hyperlink is only shown when cleanup is not enabled, since the work directory will be deleted after the run completes. Signed-off-by: Phil Ewels <phil.ewels@seqera.io>
1 parent c8ff0ce commit 66e9ea4

File tree

6 files changed

+90
-2
lines changed

6 files changed

+90
-2
lines changed

modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,18 @@ class AnsiLogObserver implements TraceObserverV2 {
416416
private final static Pattern TAG_REGEX = ~/ \((.+)\)( *)$/
417417
private final static Pattern LBL_REPLACE = ~/ \(.+\) *$/
418418

419+
// OSC 8 hyperlink escape sequences (using BEL as String Terminator)
420+
private final static String HYPERLINK_START = '\033]8;;'
421+
private final static String HYPERLINK_SEP = '\007'
422+
private final static String HYPERLINK_END = '\033]8;;\007'
423+
424+
protected static String hyperlink(String text, String url) {
425+
if( !url )
426+
return text
427+
final href = url.startsWith('/') ? 'file://' + url : url
428+
return HYPERLINK_START + href + HYPERLINK_SEP + text + HYPERLINK_END
429+
}
430+
419431
protected Ansi line(ProgressRecord stats) {
420432
final term = ansi()
421433
final float tot = stats.getTotalCount()
@@ -442,9 +454,10 @@ class AnsiLogObserver implements TraceObserverV2 {
442454
// eg. 1 of 1
443455
final numbs = " ${(int)com} of ${(int)tot}".toString()
444456

445-
// Task hash, eg: [fa/71091a]
457+
// Task hash - make clickable hyperlink to work dir when available (and cleanup not enabled)
458+
final hashDisplay = (stats.workDir && !session.config.cleanup) ? hyperlink(hh, stats.workDir) : hh
446459
term.a(Attribute.INTENSITY_FAINT).a('[').reset()
447-
term.fg(Color.BLUE).a(hh).reset()
460+
term.fg(Color.BLUE).a(hashDisplay).reset()
448461
term.a(Attribute.INTENSITY_FAINT).a('] ').reset()
449462

450463
// Only show 'process > ' if the terminal has lots of width

modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class ProgressRecord implements Cloneable {
3232
final int index
3333
final String name // process name
3434
String hash // current task hash
35+
String workDir // current task work directory URI
3536
String taskName // current task name
3637
int pending // number of new tasks ready to be submitted
3738
int submitted // number of tasks submitted for execution not yet started

modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ class WorkflowStats implements Cloneable {
301301
void markSubmitted(TaskRun task) {
302302
final state = getOrCreateRecord(task.processor)
303303
state.hash = task.hashLog
304+
state.workDir = task.workDirStr
304305
state.taskName = task.name
305306
state.pending --
306307
state.submitted ++
@@ -350,6 +351,7 @@ class WorkflowStats implements Cloneable {
350351
ProgressRecord state = getOrCreateRecord(task.processor)
351352
state.taskName = task.name
352353
state.hash = task.hashLog
354+
state.workDir = task.workDirStr
353355

354356
if( status == TaskStatus.SUBMITTED ) {
355357
state.submitted --
@@ -401,6 +403,7 @@ class WorkflowStats implements Cloneable {
401403
if( trace ) {
402404
state.cached++
403405
state.hash = task.hashLog
406+
state.workDir = task.workDirStr
404407
state.taskName = task.name
405408
// global counters
406409
this.cachedMillis += getCpuTime(trace)
@@ -409,6 +412,7 @@ class WorkflowStats implements Cloneable {
409412
else {
410413
state.stored++
411414
state.hash = 'skipped'
415+
state.workDir = null
412416
state.taskName = task.name
413417
}
414418
changeTimestamp = System.currentTimeMillis()

modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package nextflow.trace
1818

19+
import nextflow.Session
1920
import spock.lang.Specification
2021
import spock.lang.Unroll
2122

@@ -108,4 +109,63 @@ class AnsiLogObserverTest extends Specification {
108109

109110
}
110111

112+
def 'should create hyperlink' () {
113+
expect:
114+
// Local paths (starting with /) get file:// prefix added automatically
115+
AnsiLogObserver.hyperlink('hash', '/path/to/work') == '\033]8;;file:///path/to/work\007hash\033]8;;\007'
116+
// URLs with schemes are used as-is
117+
AnsiLogObserver.hyperlink('hash', 's3://bucket/path') == '\033]8;;s3://bucket/path\007hash\033]8;;\007'
118+
AnsiLogObserver.hyperlink('hash', 'gs://bucket/path') == '\033]8;;gs://bucket/path\007hash\033]8;;\007'
119+
AnsiLogObserver.hyperlink('hash', 'az://container/path') == '\033]8;;az://container/path\007hash\033]8;;\007'
120+
AnsiLogObserver.hyperlink('text', null) == 'text'
121+
AnsiLogObserver.hyperlink('text', '') == 'text'
122+
}
123+
124+
def 'should render hash as hyperlink when workDir is set' () {
125+
given:
126+
def session = Mock(Session) { getConfig() >> [cleanup: false] }
127+
def observer = new AnsiLogObserver()
128+
observer.@session = session
129+
observer.@labelWidth = 3
130+
observer.@cols = 190
131+
and:
132+
def stats = new ProgressRecord(1, 'foo')
133+
stats.submitted = 1
134+
stats.hash = '4e/486876'
135+
stats.workDir = WORKDIR
136+
137+
when:
138+
def result = observer.line(stats).toString()
139+
140+
then:
141+
result.contains('\033]8;;' + EXPECTED_HREF + '\007')
142+
result.contains('\033]8;;\007')
143+
144+
where:
145+
WORKDIR | EXPECTED_HREF
146+
'/work/4e/486876abc' | 'file:///work/4e/486876abc'
147+
's3://bucket/work/4e/486876abc' | 's3://bucket/work/4e/486876abc'
148+
}
149+
150+
def 'should not render hyperlink when cleanup is enabled' () {
151+
given:
152+
def session = Mock(Session) { getConfig() >> [cleanup: true] }
153+
def observer = new AnsiLogObserver()
154+
observer.@session = session
155+
observer.@labelWidth = 3
156+
observer.@cols = 190
157+
and:
158+
def stats = new ProgressRecord(1, 'foo')
159+
stats.submitted = 1
160+
stats.hash = '4e/486876'
161+
stats.workDir = '/work/4e/486876abc'
162+
163+
when:
164+
def result = observer.line(stats).toString()
165+
166+
then:
167+
// Should NOT contain hyperlink start sequence
168+
!result.contains('\033]8;;')
169+
}
170+
111171
}

modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class ProgressRecordTest extends Specification {
3131
with(rec) {
3232
index == 10
3333
name == 'foo'
34+
hash == null
35+
workDir == null
3436
pending == 0
3537
submitted == 0
3638
running == 0

modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,11 @@ class WorkflowStatsTest extends Specification {
222222
def PENDING = 10
223223
def SUBMITTED = 20
224224
def HASH = 'xyz'
225+
def WORKDIR = 'file:///work/xy/z123456'
225226
and:
226227
def task = Mock(TaskRun) {
227228
getHashLog() >> HASH
229+
getWorkDirStr() >> WORKDIR
228230
getProcessor() >> Mock(TaskProcessor) { getId() >> 0 }
229231
}
230232
and:
@@ -247,6 +249,7 @@ class WorkflowStatsTest extends Specification {
247249
rec.submitted == SUBMITTED +1
248250
and:
249251
rec.hash == HASH
252+
rec.workDir == WORKDIR
250253
}
251254

252255
def 'should mark running' () {
@@ -724,9 +727,11 @@ class WorkflowStatsTest extends Specification {
724727
given:
725728
def CACHED = 10
726729
def STORED = 20
730+
def WORKDIR = 'file:///work/xy/z123456'
727731
and:
728732
def task = Mock(TaskRun) {
729733
getHashLog() >> 'XYZ'
734+
getWorkDirStr() >> WORKDIR
730735
getProcessor() >> Mock(TaskProcessor) { getId() >> 0 }
731736
}
732737
and:
@@ -749,6 +754,7 @@ class WorkflowStatsTest extends Specification {
749754
stats.cachedDuration == 5.sec
750755
and:
751756
rec.hash == 'XYZ'
757+
rec.workDir == WORKDIR
752758
rec.cached == CACHED +1
753759
rec.stored == STORED
754760
}
@@ -760,6 +766,7 @@ class WorkflowStatsTest extends Specification {
760766
and:
761767
def task = Mock(TaskRun) {
762768
getHashLog() >> 'XYZ'
769+
getWorkDirStr() >> 'file:///work/xy/z123456'
763770
getProcessor() >> Mock(TaskProcessor) { getId() >> 0 }
764771
}
765772
and:
@@ -781,6 +788,7 @@ class WorkflowStatsTest extends Specification {
781788
rec.stored == STORED +1
782789
and:
783790
rec.hash == 'skipped'
791+
rec.workDir == null
784792
}
785793

786794

0 commit comments

Comments
 (0)