diff --git a/src/contour/TerminalSession.cpp b/src/contour/TerminalSession.cpp index d48572d8b1..2ecdf8591c 100644 --- a/src/contour/TerminalSession.cpp +++ b/src/contour/TerminalSession.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -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(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() @@ -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(); @@ -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; } diff --git a/src/vtbackend/HintModeHandler.cpp b/src/vtbackend/HintModeHandler.cpp index 87149216f0..7da4eb2b7d 100644 --- a/src/vtbackend/HintModeHandler.cpp +++ b/src/vtbackend/HintModeHandler.cpp @@ -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). diff --git a/src/vtbackend/HintModeHandler.h b/src/vtbackend/HintModeHandler.h index 937a727da2..d98373a510 100644 --- a/src/vtbackend/HintModeHandler.h +++ b/src/vtbackend/HintModeHandler.h @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace vtbackend @@ -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 diff --git a/src/vtbackend/Terminal.cpp b/src/vtbackend/Terminal.cpp index 00395b1754..13316b8997 100644 --- a/src/vtbackend/Terminal.cpp +++ b/src/vtbackend/Terminal.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,32 @@ namespace vtbackend namespace // {{{ helpers { + std::optional 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) @@ -1993,6 +2020,47 @@ void Terminal::openDocument(string_view data) _eventListener.openDocument(data); } +std::optional 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(*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(match.position())); + auto const endColumn = + utf8ByteOffsetToCodepointIndex(lineText, static_cast(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() diff --git a/src/vtbackend/Terminal.h b/src/vtbackend/Terminal.h index bd416d19d9..ec92eeb70b 100644 --- a/src/vtbackend/Terminal.h +++ b/src/vtbackend/Terminal.h @@ -1087,6 +1087,9 @@ class Terminal return {}; } + /// Returns the local filesystem path under the mouse cursor, if any. + [[nodiscard]] std::optional localPathAtMousePosition() const; + [[nodiscard]] ExecutionMode executionMode() const noexcept { return _executionMode; } void setExecutionMode(ExecutionMode mode); diff --git a/src/vtbackend/Terminal_test.cpp b/src/vtbackend/Terminal_test.cpp index 6d955a4ac0..0150a63634 100644 --- a/src/vtbackend/Terminal_test.cpp +++ b/src/vtbackend/Terminal_test.cpp @@ -8,23 +8,29 @@ #include #include +#include #include #include #include +#include +#include +#include #include #include 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; @@ -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.