Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/console/PhutilConsoleFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/**
* Utilities for console formatting.
*/
final class PhutilConsoleFormatter {

private static $disableANSI = false;
private static $colorCodes = array(
'red' => 31,
'green' => 32,
'yellow' => 33,
'blue' => 34,
'magenta' => 35,
'cyan' => 36,
'white' => 37,
'default' => 39,
);

/**
* Disable ANSI color and formatting codes.
*/
public static function disableANSI($disable) {
self::$disableANSI = $disable;
}

/**
* Get the current ANSI disabled state.
*/
public static function getDisableANSI() {
return self::$disableANSI;
}

/**
* Format a string with ANSI color codes.
*/
public static function formatString($format, $string = null) {
if (self::$disableANSI) {
return $string;
}

$colors = self::$colorCodes;
$codes = array();

if (strpos($format, 'bold') !== false) {
$codes[] = 1;
}

foreach ($colors as $color => $code) {
if (strpos($format, $color) !== false) {
$codes[] = $code;
}
}

if (empty($codes)) {
return $string;
}

$prefix = sprintf('\033[%sm', implode(';', $codes));
$suffix = '\033[0m';

return $prefix.$string.$suffix;
}

/**
* Strip ANSI escape sequences from a string.
*/
public static function stripANSI($string) {
return preg_replace('/\x1b\[[0-9;]*m/', '', $string);
}

/**
* Escape format codes in a string.
*/
public static function escapeFormat($format) {
return addcslashes($format, '*_#');
}

/**
* Unescape format codes in a string.
*/
public static function unescapeFormat($format) {
return preg_replace('/\\\\(\*\*.*\*\*|__.*__|##.*##)/sU', '\1', $format);
}

/**
* Create a terminal hyperlink using OSC 8 escape sequences.
*
* @param string $url The URL to link to
* @param string $text The visible text to display
* @return string Terminal hyperlink or just text if ANSI is disabled
*/
public static function formatHyperlink($url, $text) {
if (self::getDisableANSI()) {
// If ANSI is disabled, just return the text
return $text;
}

// OSC 8 hyperlink format: \033]8;;URL\033\TEXT\033]8;;\033\
$esc = chr(27);
return $esc.']8;;'.$url.$esc.'\\'.$text.$esc.']8;;'.$esc.'\\';
}

}
141 changes: 141 additions & 0 deletions src/console/__tests__/PhutilConsoleFormatterTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

final class PhutilConsoleFormatterTestCase extends PhutilTestCase {

public function testFormatHyperlink() {
$url = 'https://example.com/D12345';
$text = 'D12345';

// Test with ANSI enabled (default)
PhutilConsoleFormatter::disableANSI(false);
$result = PhutilConsoleFormatter::formatHyperlink($url, $text);

// Expected OSC 8 hyperlink format: \033]8;;URL\033\TEXT\033]8;;\033\
$esc = chr(27);
$expected = $esc.']8;;'.$url.$esc.'\\'.$text.$esc.']8;;'.$esc.'\\';

$this->assertEqual(
$expected,
$result,
pht('formatHyperlink should produce OSC 8 escape sequences when ANSI is enabled'));

// Test with ANSI disabled
PhutilConsoleFormatter::disableANSI(true);
$result = PhutilConsoleFormatter::formatHyperlink($url, $text);

$this->assertEqual(
$text,
$result,
pht('formatHyperlink should return plain text when ANSI is disabled'));

// Reset ANSI state
PhutilConsoleFormatter::disableANSI(false);
}

public function testFormatHyperlinkEdgeCases() {
// Test empty URL
$result = PhutilConsoleFormatter::formatHyperlink('', 'text');
$esc = chr(27);
$expected = $esc.']8;;'.$esc.'\\'.'text'.$esc.']8;;'.$esc.'\\';
$this->assertEqual($expected, $result);

// Test empty text
$result = PhutilConsoleFormatter::formatHyperlink('https://example.com', '');
$esc = chr(27);
$expected = $esc.']8;;https://example.com'.$esc.'\\'.$esc.']8;;'.$esc.'\\';
$this->assertEqual($expected, $result);

// Test special characters in URL
$url = 'https://example.com/path?query=value&other=123#fragment';
$text = 'Link Text';
$result = PhutilConsoleFormatter::formatHyperlink($url, $text);
$esc = chr(27);
$expected = $esc.']8;;'.$url.$esc.'\\'.$text.$esc.']8;;'.$esc.'\\';
$this->assertEqual($expected, $result);
}

public function testDisableANSI() {
// Test initial state
$this->assertFalse(
PhutilConsoleFormatter::getDisableANSI(),
pht('ANSI should be enabled by default'));

// Test disabling
PhutilConsoleFormatter::disableANSI(true);
$this->assertTrue(
PhutilConsoleFormatter::getDisableANSI(),
pht('ANSI should be disabled after calling disableANSI(true)'));

// Test re-enabling
PhutilConsoleFormatter::disableANSI(false);
$this->assertFalse(
PhutilConsoleFormatter::getDisableANSI(),
pht('ANSI should be enabled after calling disableANSI(false)'));
}

public function testFormatString() {
PhutilConsoleFormatter::disableANSI(false);

// Test bold formatting
$result = PhutilConsoleFormatter::formatString('bold', 'test');
$this->assertEqual("\033[1mtest\033[0m", $result);

// Test color formatting
$result = PhutilConsoleFormatter::formatString('red', 'test');
$this->assertEqual("\033[31mtest\033[0m", $result);

// Test combined formatting
$result = PhutilConsoleFormatter::formatString('bold red', 'test');
$this->assertEqual("\033[1;31mtest\033[0m", $result);

// Test with ANSI disabled
PhutilConsoleFormatter::disableANSI(true);
$result = PhutilConsoleFormatter::formatString('bold red', 'test');
$this->assertEqual('test', $result);

// Reset ANSI state
PhutilConsoleFormatter::disableANSI(false);
}

public function testStripANSI() {
$input = "\033[1;31mHello\033[0m \033[32mWorld\033[0m";
$expected = "Hello World";
$result = PhutilConsoleFormatter::stripANSI($input);

$this->assertEqual(
$expected,
$result,
pht('stripANSI should remove all ANSI escape sequences'));

// Test with hyperlink sequences
$esc = chr(27);
$hyperlink = $esc.']8;;https://example.com'.$esc.'\\text'.$esc.']8;;'.$esc.'\\';
$result = PhutilConsoleFormatter::stripANSI($hyperlink);

// stripANSI only removes color sequences, not OSC 8
$this->assertEqual($hyperlink, $result);
}

public function testEscapeFormat() {
$input = '**bold** __italic__ ##code##';
$expected = '\\*\\*bold\\*\\* \\_\\_italic\\_\\_ \\#\\#code\\#\\#';
$result = PhutilConsoleFormatter::escapeFormat($input);

$this->assertEqual(
$expected,
$result,
pht('escapeFormat should escape format characters'));
}

public function testUnescapeFormat() {
$input = '\\\\(**bold**) \\\\(__italic__) \\\\(##code##)';
$expected = '(**bold**) (__italic__) (##code##)';
$result = PhutilConsoleFormatter::unescapeFormat($input);

$this->assertEqual(
$expected,
$result,
pht('unescapeFormat should unescape format sequences'));
}

}
50 changes: 49 additions & 1 deletion src/flow/field/ICFlowMonogramField.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ public function getDefaultFieldOrder() {
}

protected function renderValues(array $values) {
return 'D'.idx($values, 'revision-id');
$revision_id = idx($values, 'revision-id');
$monogram = 'D'.$revision_id;

// Create clickable link if we have a revision ID
if ($revision_id) {
$url = $this->buildDifferentialURL($revision_id);
if ($url) {
return PhutilConsoleFormatter::formatHyperlink($url, $monogram);
}
}

return $monogram;
}

public function getValues(ICFlowFeature $feature) {
Expand All @@ -28,4 +39,41 @@ public function getValues(ICFlowFeature $feature) {
return null;
}

/**
* Build the URL for a differential revision.
*
* @param int $revision_id The revision ID (e.g., 123 for D123)
* @return string|null The full URL or null if no base URL configured
*/
private function buildDifferentialURL($revision_id) {
// Try to get the base URL from configuration
$base_url = $this->getDifferentialBaseURL();
if (!$base_url) {
return null;
}

// Ensure base URL ends with a slash
$base_url = rtrim($base_url, '/');

return $base_url.'/D'.$revision_id;
}

/**
* Get the base URL for differential from configuration.
*
* @return string|null The base URL or null if not configured
*/
private function getDifferentialBaseURL() {
// Try multiple configuration sources in order of preference

// 1. Check for Uber-specific configuration
$uber_url = getenv('UBER_DIFFERENTIAL_BASE_URL');
if ($uber_url) {
return $uber_url;
}

// 2. Default to Uber internal URL
return 'https://code.uberinternal.com';
}

}
Loading