Skip to content

Commit 8d266c7

Browse files
authored
Add OSC 8 hyperlinks to task hashes in progress output (#6799)
1 parent 908e4c7 commit 8d266c7

File tree

7 files changed

+92
-1
lines changed

7 files changed

+92
-1
lines changed

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

Lines changed: 15 additions & 1 deletion
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()
@@ -443,8 +455,10 @@ class AnsiLogObserver implements TraceObserverV2 {
443455
final numbs = " ${(int)com} of ${(int)tot}".toString()
444456

445457
// Task hash, eg: [fa/71091a]
458+
// make clickable hyperlink to work dir inferred from session workDir and task hash
459+
final hashDisplay = (stats.workDir && !session.config.cleanup) ? hyperlink(hh, stats.workDir) : hh
446460
term.a(Attribute.INTENSITY_FAINT).a('[').reset()
447-
term.fg(Color.BLUE).a(hh).reset()
461+
term.fg(Color.BLUE).a(hashDisplay).reset()
448462
term.a(Attribute.INTENSITY_FAINT).a('] ').reset()
449463

450464
// 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

plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class TowerJsonGeneratorTest extends Specification {
113113
progress.get(0) == [
114114
index:1,
115115
name: 'foo',
116+
workDir: null,
116117
pending: 0,
117118
submitted: 0,
118119
running: 0,
@@ -133,6 +134,7 @@ class TowerJsonGeneratorTest extends Specification {
133134
progress[1] == [
134135
index:2,
135136
name: 'bar',
137+
workDir: null,
136138
pending: 1,
137139
submitted: 2,
138140
running: 3,

0 commit comments

Comments
 (0)