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
22 changes: 20 additions & 2 deletions src/contour/TerminalSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include <QtCore/QProcess>
#include <QtCore/QStandardPaths>
#include <QtCore/QTimer>
#include <QtCore/QUrl>
#include <QtGui/QClipboard>
#include <QtGui/QDesktopServices>
#include <QtGui/QGuiApplication>
Expand Down Expand Up @@ -627,7 +628,18 @@ void TerminalSession::copyToClipboard(std::string_view data)
void TerminalSession::openDocument(std::string_view fileOrUrl)
{
sessionLog()("openDocument: {}\n", fileOrUrl);
QDesktopServices::openUrl(QUrl(QString::fromStdString(std::string(fileOrUrl))));
auto const text = QString::fromUtf8(fileOrUrl.data(), static_cast<int>(fileOrUrl.size()));
auto url = QUrl(text);

if (url.scheme().isEmpty())
{
auto const fileInfo = QFileInfo(text);
if (fileInfo.exists())
url = QUrl::fromLocalFile(fileInfo.absoluteFilePath());
}

if (!QDesktopServices::openUrl(url))
errorLog()("Could not open document \"{}\".", fileOrUrl);
}

void TerminalSession::inspect()
Expand Down Expand Up @@ -1156,7 +1168,8 @@ void TerminalSession::sendMouseMoveEvent(vtbackend::Modifiers modifiers,
{
// Change cursor shape only when changing grid cell.
_currentMousePosition = pos;
if (terminal().isMouseHoveringHyperlink())
if (terminal().isMouseHoveringHyperlink()
|| (modifiers.contains(vtbackend::Modifier::Control) && terminal().localPathAtMousePosition()))
_display->setMouseCursorShape(MouseCursorShape::PointingHand);
else
setDefaultCursor();
Expand Down Expand Up @@ -1354,6 +1367,11 @@ bool TerminalSession::operator()(actions::FollowHyperlink)
followHyperlink(*hyperlink);
return true;
}
if (auto const path = terminal().localPathAtMousePosition())
{
openDocument(*path);
return true;
}
return false;
}

Expand Down
15 changes: 1 addition & 14 deletions src/vtbackend/HintModeHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,7 @@ using namespace std;
namespace vtbackend
{

/// Converts a UTF-8 byte offset within @p text to the corresponding codepoint index.
///
/// In the UTF-8 strings produced by Line::toUtf8(), each grid cell emits exactly one
/// codepoint (wide-character continuation cells emit a space). Therefore the codepoint
/// index equals the column index for these strings.
///
/// @note This function counts UTF-8 leading bytes and does not perform grapheme-cluster
/// segmentation. The returned index equals a terminal grid column only when the
/// input was produced by Line::toUtf8(), which guarantees one codepoint per cell.
///
/// @param text The UTF-8 encoded string.
/// @param byteOffset The byte offset to convert.
/// @return The number of codepoints before @p byteOffset.
static constexpr auto utf8ByteOffsetToCodepointIndex(string_view text, size_t byteOffset) noexcept -> size_t
auto utf8ByteOffsetToCodepointIndex(string_view text, size_t byteOffset) noexcept -> size_t
{
auto const limit = min(byteOffset, text.size());
// Count bytes that are NOT continuation bytes (10xxxxxx).
Expand Down
9 changes: 9 additions & 0 deletions src/vtbackend/HintModeHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <functional>
#include <regex>
#include <string>
#include <string_view>
#include <vector>

namespace vtbackend
Expand Down Expand Up @@ -141,4 +142,12 @@ class HintModeHandler
/// Returns the URL unchanged if it does not start with "file://".
[[nodiscard]] auto extractPathFromFileUrl(std::string const& url) -> std::string;

/// Converts a UTF-8 byte offset within @p text to the corresponding codepoint index.
///
/// In the UTF-8 strings produced by Line::toUtf8(), each grid cell emits exactly one
/// codepoint (wide-character continuation cells emit a space). Therefore the codepoint
/// index equals the column index for these strings.
[[nodiscard]] auto utf8ByteOffsetToCodepointIndex(std::string_view text, size_t byteOffset) noexcept
-> size_t;

} // namespace vtbackend
68 changes: 68 additions & 0 deletions src/vtbackend/Terminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#include <filesystem>
#include <format>
#include <ranges>
#include <regex>
#include <string>
#include <string_view>
#include <utility>
Expand All @@ -54,6 +55,32 @@ namespace vtbackend

namespace // {{{ helpers
{
std::optional<std::string> resolveExistingLocalPath(std::string const& cwd,
std::string const& home,
std::string const& match)
{
auto candidate = std::filesystem::path {};
if (match.starts_with("/"))
candidate = match;
else if (match.starts_with("~/"))
{
if (home.empty())
return std::nullopt;
candidate = std::filesystem::path(home) / match.substr(2);
}
else
{
if (cwd.empty())
return std::nullopt;
candidate = std::filesystem::path(cwd) / match;
}

auto ec = std::error_code {};
if (!std::filesystem::exists(candidate, ec))
return std::nullopt;
return candidate.lexically_normal().string();
}

constexpr size_t MaxColorPaletteSaveStackSize = 10;

void trimSpaceRight(string& value)
Expand Down Expand Up @@ -1993,6 +2020,47 @@ void Terminal::openDocument(string_view data)
_eventListener.openDocument(data);
}

std::optional<std::string> Terminal::localPathAtMousePosition() const
{
auto const mousePosition = currentMouseGridPosition();
if (!mousePosition)
return std::nullopt;

auto const lineText = currentScreen().lineTextAt(mousePosition->line, false, false);
auto const mouseColumn = static_cast<size_t>(*mousePosition->column);
auto const cwd = extractPathFromFileUrl(currentWorkingDirectory());
auto const* const homeEnv = std::getenv("HOME");
auto const home = std::string(homeEnv ? homeEnv : "");

static auto const LocalPathRegex = [] {
return std::regex(R"((?:~?/[\w./-]+|\.{1,2}/[\w./-]+|[\w.][\w.-]*/[\w./-]+|[\w.][\w.-]+))",
std::regex_constants::ECMAScript | std::regex_constants::optimize);
}();

auto matchIter = std::sregex_iterator(lineText.begin(), lineText.end(), LocalPathRegex);
auto const matchEnd = std::sregex_iterator();
for (; matchIter != matchEnd; ++matchIter)
{
auto const& match = *matchIter;
if (match.empty())
continue;

auto const startColumn =
utf8ByteOffsetToCodepointIndex(lineText, static_cast<size_t>(match.position()));
auto const endColumn =
utf8ByteOffsetToCodepointIndex(lineText, static_cast<size_t>(match.position() + match.length()))
- 1;

if (mouseColumn < startColumn || mouseColumn > endColumn)
continue;

if (auto path = resolveExistingLocalPath(cwd, home, match.str()))
return path;
}

return std::nullopt;
}

// {{{ Hint mode

void Terminal::refreshHints()
Expand Down
3 changes: 3 additions & 0 deletions src/vtbackend/Terminal.h
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,9 @@ class Terminal
return {};
}

/// Returns the local filesystem path under the mouse cursor, if any.
[[nodiscard]] std::optional<std::string> localPathAtMousePosition() const;

[[nodiscard]] ExecutionMode executionMode() const noexcept { return _executionMode; }
void setExecutionMode(ExecutionMode mode);

Expand Down
73 changes: 73 additions & 0 deletions src/vtbackend/Terminal_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,29 @@

#include <crispy/App.h>
#include <crispy/times.h>
#include <crispy/utils.h>

#include <libunicode/convert.h>

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

#include <filesystem>
#include <format>
#include <fstream>
#include <string>
#include <vector>

using namespace std;
using namespace std::chrono_literals;
using vtbackend::CellFlag;
using vtbackend::CellLocation;
using vtbackend::ColumnCount;
using vtbackend::ColumnOffset;
using vtbackend::LineCount;
using vtbackend::LineOffset;
using vtbackend::MockTerm;
using vtbackend::Modifier;
using vtbackend::PageSize;
using vtbackend::SmoothScrollResult;

Expand Down Expand Up @@ -166,6 +172,73 @@ TEST_CASE("Terminal.ModifierKeysDoNotScrollViewport", "[terminal]")
}
}

TEST_CASE("Terminal.localPathAtMousePosition", "[terminal]")
{
namespace fs = std::filesystem;

auto const tmpRoot =
fs::temp_directory_path()
/ std::format("contour-local-path-{}", std::chrono::steady_clock::now().time_since_epoch().count());
fs::create_directories(tmpRoot / "nested");
{
auto file = std::ofstream(tmpRoot / "nested" / "file.txt");
file << "test";
}

auto const cleanup = crispy::finally { [&]() { fs::remove_all(tmpRoot); } };
auto constexpr PixelCoordinate = vtbackend::PixelCoordinate {};
auto constexpr UiHandledHint = false;

SECTION("relative path")
{
auto mc = MockTerm { PageSize { LineCount(2), ColumnCount(80) } };
auto& terminal = mc.terminal;
terminal.setCurrentWorkingDirectory("file://" + tmpRoot.string());
mc.writeToScreen("open nested/file.txt now");

terminal.sendMouseMoveEvent(Modifier::None,
CellLocation { .line = LineOffset(0), .column = ColumnOffset(10) },
PixelCoordinate,
UiHandledHint);

auto const path = terminal.localPathAtMousePosition();
REQUIRE(path.has_value());
CHECK(*path == (tmpRoot / "nested" / "file.txt").string());
}

SECTION("absolute path")
{
auto mc = MockTerm { PageSize { LineCount(2), ColumnCount(240) } };
auto& terminal = mc.terminal;
auto const absolutePath = (tmpRoot / "nested" / "file.txt").string();
mc.writeToScreen("open " + absolutePath);

terminal.sendMouseMoveEvent(Modifier::None,
CellLocation { .line = LineOffset(0), .column = ColumnOffset(8) },
PixelCoordinate,
UiHandledHint);

auto const path = terminal.localPathAtMousePosition();
REQUIRE(path.has_value());
CHECK(*path == absolutePath);
}

SECTION("missing path")
{
auto mc = MockTerm { PageSize { LineCount(2), ColumnCount(80) } };
auto& terminal = mc.terminal;
terminal.setCurrentWorkingDirectory("file://" + tmpRoot.string());
mc.writeToScreen("open nested/missing.txt now");

terminal.sendMouseMoveEvent(Modifier::None,
CellLocation { .line = LineOffset(0), .column = ColumnOffset(10) },
PixelCoordinate,
UiHandledHint);

CHECK_FALSE(terminal.localPathAtMousePosition().has_value());
}
}

TEST_CASE("Terminal.AutoScrollOnUpdate", "[terminal]")
{
// Set up a terminal with history capacity to allow scrollback.
Expand Down
Loading