diff --git a/NEWS.md b/NEWS.md index d72e384d..86268c31 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,11 @@ Features: don't have one. Setting an opid allows messages to show up in the Gantt chart view. * Add support for GitHub Markdown Alerts. +* Added the `:xopen` command that will open the given paths + using an external opener like `open` or `xdg-open`. +* Clicking on a link in a markdown file will open the Actions + with options for opening the link target in lnav, opening the + target with `:xopen`, or copying the link to a clipboard. Interface Changes: * In the Gantt chart view, pressing `ENTER` will focus on diff --git a/docs/schemas/config-v1.schema.json b/docs/schemas/config-v1.schema.json index 8b28fc4b..26c2078e 100644 --- a/docs/schemas/config-v1.schema.json +++ b/docs/schemas/config-v1.schema.json @@ -184,7 +184,7 @@ "properties": { "test": { "title": "/tuning/clipboard/impls//test", - "description": "The command that checks", + "description": "The command that checks if a clipboard command is available", "type": "string", "examples": [ "command -v pbcopy" @@ -209,6 +209,46 @@ }, "additionalProperties": false }, + "external-opener": { + "description": "Settings related to opening external files/URLs", + "title": "/tuning/external-opener", + "type": "object", + "properties": { + "impls": { + "description": "External opener implementations", + "title": "/tuning/external-opener/impls", + "type": "object", + "patternProperties": { + "^([\\w\\-]+)$": { + "description": "External opener implementation", + "title": "/tuning/external-opener/impls/", + "type": "object", + "properties": { + "test": { + "title": "/tuning/external-opener/impls//test", + "description": "The command that checks if an external opener is available", + "type": "string", + "examples": [ + "command -v open" + ] + }, + "command": { + "title": "/tuning/external-opener/impls//command", + "description": "The command used to open a file or URL", + "type": "string", + "examples": [ + "open" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "url-scheme": { "description": "Settings related to custom URL handling", "title": "/tuning/url-scheme", diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f439a771..06ec6fdb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -417,6 +417,7 @@ add_library( elem_to_json.cc environ_vtab.cc extension-functions.cc + external_opener.cc field_overlay_source.cc file_collection.cc file_converter_manager.cc @@ -492,6 +493,7 @@ add_library( styling.cc text_anonymizer.cc text_format.cc + text_link_handler.cc text_overlay_menu.cc textfile_highlighters.cc textfile_sub_source.cc @@ -533,6 +535,8 @@ add_library( doc_status_source.hh dump_internals.hh elem_to_json.hh + external_opener.hh + external_opener.cfg.hh field_overlay_source.hh file_collection.hh file_converter_manager.hh @@ -618,6 +622,7 @@ add_library( termios_guard.hh text_anonymizer.hh text_format.hh + text_link_handler.hh text_overlay_menu.hh textfile_highlighters.hh textfile_sub_source.hh diff --git a/src/Makefile.am b/src/Makefile.am index df13df6d..ff43ae00 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -237,6 +237,8 @@ noinst_HEADERS = \ dump_internals.hh \ elem_to_json.hh \ environ_vtab.hh \ + external_opener.hh \ + external_opener.cfg.hh \ field_overlay_source.hh \ file_collection.hh \ file_converter_manager.hh \ @@ -348,6 +350,7 @@ noinst_HEADERS = \ term_extra.hh \ text_anonymizer.hh \ text_format.hh \ + text_link_handler.hh \ text_overlay_menu.hh \ textfile_highlighters.hh \ textfile_sub_source.hh \ @@ -436,6 +439,7 @@ libdiag_a_SOURCES = \ elem_to_json.cc \ environ_vtab.cc \ extension-functions.cc \ + external_opener.cc \ field_overlay_source.cc \ file_collection.cc \ file_converter_manager.cc \ @@ -506,6 +510,7 @@ libdiag_a_SOURCES = \ styling.cc \ text_anonymizer.cc \ text_format.cc \ + text_link_handler.cc \ text_overlay_menu.cc \ textfile_sub_source.cc \ timer.cc \ diff --git a/src/base/attr_line.cc b/src/base/attr_line.cc index a0c55eb0..61edca1a 100644 --- a/src/base/attr_line.cc +++ b/src/base/attr_line.cc @@ -703,9 +703,8 @@ find_string_attr(const string_attrs_t& sa, const string_attr_type_base* type, int start) { - string_attrs_t::const_iterator iter; - - for (iter = sa.begin(); iter != sa.end(); ++iter) { + auto iter = sa.begin(); + for (; iter != sa.end(); ++iter) { if (iter->sa_type == type && iter->sa_range.lr_start >= start) { break; } diff --git a/src/base/auto_fd.cc b/src/base/auto_fd.cc index 0853c3ac..647da3a4 100644 --- a/src/base/auto_fd.cc +++ b/src/base/auto_fd.cc @@ -79,12 +79,18 @@ auto_fd::openpt(int flags) return Ok(auto_fd{rc}); } -auto_fd::auto_fd(int fd) : af_fd(fd) +auto_fd:: +auto_fd(int fd) + : af_fd(fd) { require(fd >= -1); } -auto_fd::auto_fd(auto_fd&& af) noexcept : af_fd(af.release()) {} +auto_fd:: +auto_fd(auto_fd&& af) noexcept + : af_fd(af.release()) +{ +} auto_fd auto_fd::dup() const @@ -98,11 +104,18 @@ auto_fd::dup() const return auto_fd{new_fd}; } -auto_fd::~auto_fd() +auto_fd::~ +auto_fd() { this->reset(); } +void +auto_fd::copy_to(int fd) const +{ + dup2(this->get(), fd); +} + void auto_fd::reset(int fd) { @@ -184,7 +197,8 @@ auto_pipe::for_child_fd(int child_fd) return Ok(std::move(retval)); } -auto_pipe::auto_pipe(int child_fd, int child_flags) +auto_pipe:: +auto_pipe(int child_fd, int child_flags) : ap_child_flags(child_flags), ap_child_fd(child_fd) { switch (child_fd) { diff --git a/src/base/auto_fd.hh b/src/base/auto_fd.hh index 9fb3c912..2fbacb86 100644 --- a/src/base/auto_fd.hh +++ b/src/base/auto_fd.hh @@ -146,6 +146,8 @@ public: return retval; } + void copy_to(int fd) const; + /** * @return The file descriptor. */ diff --git a/src/base/fs_util.tests.cc b/src/base/fs_util.tests.cc index 9ed33776..27cd928d 100644 --- a/src/base/fs_util.tests.cc +++ b/src/base/fs_util.tests.cc @@ -27,6 +27,7 @@ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +#include #include #include "base/fs_util.hh" diff --git a/src/external_opener.cc b/src/external_opener.cc new file mode 100644 index 00000000..7808b962 --- /dev/null +++ b/src/external_opener.cc @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2024, Timothy Stack + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of Timothy Stack nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "external_opener.hh" + +#include +#include + +#include "base/auto_pid.hh" +#include "base/fs_util.hh" +#include "base/injector.hh" +#include "external_opener.cfg.hh" +#include "fmt/format.h" + +namespace lnav::external_opener { + +static std::optional +get_impl() +{ + const auto& cfg = injector::get(); + + for (const auto& pair : cfg.c_impls) { + const auto full_cmd = fmt::format(FMT_STRING("{} > /dev/null 2>&1"), + pair.second.i_test_command); + + log_debug("testing opener impl %s using: %s", + pair.first.c_str(), + full_cmd.c_str()); + if (system(full_cmd.c_str()) == 0) { + log_info("detected opener: %s", pair.first.c_str()); + return pair.second; + } + } + + return std::nullopt; +} + +Result +for_href(const std::string& href) +{ + static const auto IMPL = get_impl(); + + if (!IMPL) { + const static std::string MSG = "no external opener found"; + + return Err(MSG); + } + + auto child_pid_res = lnav::pid::from_fork(); + if (child_pid_res.isErr()) { + return Err(child_pid_res.unwrapErr()); + } + + auto child_pid = child_pid_res.unwrap(); + if (child_pid.in_child()) { + auto open_res + = lnav::filesystem::open_file("/dev/null", O_RDONLY | O_CLOEXEC); + open_res.then([](auto_fd&& fd) { + fd.copy_to(STDIN_FILENO); + fd.copy_to(STDOUT_FILENO); + }); + + execlp(IMPL->i_command.c_str(), + IMPL->i_command.c_str(), + href.c_str(), + nullptr); + _exit(EXIT_FAILURE); + } + + return Ok(); +} + +} // namespace lnav::external_opener diff --git a/src/external_opener.cfg.hh b/src/external_opener.cfg.hh new file mode 100644 index 00000000..87a676ac --- /dev/null +++ b/src/external_opener.cfg.hh @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024, Timothy Stack + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of Timothy Stack nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef lnav_external_opener_cfg_hh +#define lnav_external_opener_cfg_hh + +#include +#include + +namespace lnav::external_opener { + +struct impl { + std::string i_test_command; + std::string i_command; +}; + +struct config { + std::map c_impls; +}; + +} // namespace lnav::external_opener + +#endif diff --git a/src/external_opener.hh b/src/external_opener.hh new file mode 100644 index 00000000..6ae12000 --- /dev/null +++ b/src/external_opener.hh @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024, Timothy Stack + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of Timothy Stack nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef lnav_external_opener_hh +#define lnav_external_opener_hh + +#include + +#include "base/result.h" + +namespace lnav::external_opener { + +Result for_href(const std::string& href); + +} + +#endif diff --git a/src/file_collection.cc b/src/file_collection.cc index 4884a96f..c693e348 100644 --- a/src/file_collection.cc +++ b/src/file_collection.cc @@ -135,7 +135,8 @@ file_collection::close_files(const std::vector>& files) } else { this->fc_file_names.erase(lf->get_filename()); } - auto file_iter = find(this->fc_files.begin(), this->fc_files.end(), lf); + auto file_iter + = std::find(this->fc_files.begin(), this->fc_files.end(), lf); if (file_iter != this->fc_files.end()) { this->fc_files.erase(file_iter); } @@ -220,6 +221,9 @@ file_collection::merge(file_collection& other) errs->insert(new_errors.begin(), new_errors.end()); } + if (!other.fc_file_names.empty()) { + this->fc_files_generation += 1; + } for (const auto& fn_pair : other.fc_file_names) { this->fc_file_names[fn_pair.first] = fn_pair.second; } diff --git a/src/internals/cmd-ref.rst b/src/internals/cmd-ref.rst index a8a10d5a..9fe16aca 100644 --- a/src/internals/cmd-ref.rst +++ b/src/internals/cmd-ref.rst @@ -1728,6 +1728,27 @@ ---- +.. _xopen: + +:xopen *path* +^^^^^^^^^^^^^ + + Use an external command to open the given file(s) + + **Parameters** + * **path** --- The path to the file to open + + **Examples** + To open the file '/path/to/file': + + .. code-block:: lnav + + :xopen /path/to/file + + +---- + + .. _zoom_to: :zoom-to *zoom-level* diff --git a/src/lnav_commands.cc b/src/lnav_commands.cc index 56889403..dd21b47f 100644 --- a/src/lnav_commands.cc +++ b/src/lnav_commands.cc @@ -58,6 +58,7 @@ #include "curl_looper.hh" #include "date/tz.h" #include "db_sub_source.hh" +#include "external_opener.hh" #include "field_overlay_source.hh" #include "fmt/printf.h" #include "hasher.hh" @@ -3071,6 +3072,42 @@ com_open(exec_context& ec, std::string cmdline, std::vector& args) struct stat st; size_t url_index; +#ifdef HAVE_LIBCURL + if (startswith(fn, "file:")) { + auto* cu = curl_url(); + auto set_rc = curl_url_set(cu, CURLUPART_URL, fn.c_str(), 0); + if (set_rc != CURLUE_OK) { + return Err(lnav::console::user_message::error( + attr_line_t("invalid URL: ") + .append(lnav::roles::file(fn))) + .with_reason(curl_url_strerror(set_rc))); + } + + char* path_part; + auto get_rc = curl_url_get(cu, CURLUPART_PATH, &path_part, 0); + if (get_rc != CURLUE_OK) { + return Err(lnav::console::user_message::error( + attr_line_t("cannot get path from URL: ") + .append(lnav::roles::file(fn))) + .with_reason(curl_url_strerror(get_rc))); + } + char* frag_part = nullptr; + get_rc = curl_url_get(cu, CURLUPART_FRAGMENT, &frag_part, 0); + if (get_rc != CURLUE_OK && get_rc != CURLUE_NO_FRAGMENT) { + return Err(lnav::console::user_message::error( + attr_line_t("cannot get fragment from URL: ") + .append(lnav::roles::file(fn))) + .with_reason(curl_url_strerror(get_rc))); + } + + if (frag_part != nullptr && frag_part[0]) { + fn = fmt::format(FMT_STRING("{}#{}"), path_part, frag_part); + } else { + fn = path_part; + } + } +#endif + if (is_url(fn.c_str())) { #ifndef HAVE_LIBCURL retval = "error: lnav was not compiled with libcurl"; @@ -3439,6 +3476,60 @@ com_open(exec_context& ec, std::string cmdline, std::vector& args) return Ok(retval); } +static Result +com_xopen(exec_context& ec, std::string cmdline, std::vector& args) +{ + static const intern_string_t SRC = intern_string::lookup("path"); + std::string retval; + + if (args.empty()) { + args.emplace_back("filename"); + return Ok(std::string()); + } + + if (lnav_data.ld_flags & LNF_SECURE_MODE) { + return ec.make_error("{} -- unavailable in secure mode", args[0]); + } + + if (args.size() < 2) { + return ec.make_error("expecting file name to open"); + } + + std::vector word_exp; + std::string pat; + file_collection fc; + + pat = trim(remaining_args(cmdline, args)); + + shlex lexer(pat); + auto split_args_res = lexer.split(ec.create_resolver()); + if (split_args_res.isErr()) { + auto split_err = split_args_res.unwrapErr(); + auto um + = lnav::console::user_message::error("unable to parse file names") + .with_reason(split_err.te_msg) + .with_snippet(lnav::console::snippet::from( + SRC, lexer.to_attr_line(split_err))); + + return Err(um); + } + + auto split_args = split_args_res.unwrap() + | lnav::itertools::map([](const auto& elem) { return elem.se_value; }); + for (auto fn : split_args) { + auto open_res = lnav::external_opener::for_href(fn); + if (open_res.isErr()) { + auto um = lnav::console::user_message::error( + attr_line_t("Unable to open file: ") + .append(lnav::roles::file(fn))) + .with_reason(open_res.unwrapErr()); + return Err(um); + } + } + + return Ok(retval); +} + static Result com_close(exec_context& ec, std::string cmdline, std::vector& args) { @@ -6612,6 +6703,14 @@ readline_context::command_t STD_COMMANDS[] = { .with_example({"To open the file '/path/to/file'", "/path/to/file"}) .with_example({"To open the remote file '/var/log/syslog.log'", "dean@host1.example.com:/var/log/syslog.log"})}, + {"xopen", + com_xopen, + + help_text(":xopen") + .with_summary("Use an external command to open the given file(s)") + .with_parameter( + help_text{"path", "The path to the file to open"}.one_or_more()) + .with_example({"To open the file '/path/to/file'", "/path/to/file"})}, {"hide-file", com_hide_file, diff --git a/src/lnav_config.cc b/src/lnav_config.cc index a34f184c..e951a4c7 100644 --- a/src/lnav_config.cc +++ b/src/lnav_config.cc @@ -101,6 +101,9 @@ static auto tc = injector::bind::to_instance( static auto scc = injector::bind::to_instance( +[]() { return &lnav_config.lc_sysclip; }); +static auto oc = injector::bind::to_instance( + +[]() { return &lnav_config.lc_opener; }); + static auto uh = injector::bind::to_instance( +[]() { return &lnav_config.lc_url_handlers; }); @@ -1347,7 +1350,8 @@ static const struct json_path_container sysclip_impl_cmd_handlers = json_path_co static const struct json_path_container sysclip_impl_handlers = { yajlpp::property_handler("test") .with_synopsis("") - .with_description("The command that checks") + .with_description( + "The command that checks if a clipboard command is available") .with_example("command -v pbcopy") .for_field(&sysclip::clipboard::c_test_command), yajlpp::property_handler("general") @@ -1386,6 +1390,44 @@ static const struct json_path_container sysclip_handlers = { .with_children(sysclip_impls_handlers), }; +static const json_path_container opener_impl_handlers = { + yajlpp::property_handler("test") + .with_synopsis("") + .with_description( + "The command that checks if an external opener is available") + .with_example("command -v open") + .for_field(&lnav::external_opener::impl::i_test_command), + yajlpp::property_handler("command") + .with_description("The command used to open a file or URL") + .with_example("open") + .for_field(&lnav::external_opener::impl::i_command), +}; + +static const json_path_container opener_impls_handlers = { + yajlpp::pattern_property_handler("(?[\\w\\-]+)") + .with_synopsis("") + .with_description("External opener implementation") + .with_obj_provider( + [](const yajlpp_provider_context& ypc, _lnav_config* root) { + auto& retval = root->lc_opener + .c_impls[ypc.get_substr("opener_impl_name")]; + return &retval; + }) + .with_path_provider<_lnav_config>( + [](struct _lnav_config* cfg, std::vector& paths_out) { + for (const auto& iter : cfg->lc_opener.c_impls) { + paths_out.emplace_back(iter.first); + } + }) + .with_children(opener_impl_handlers), +}; + +static const struct json_path_container opener_handlers = { + yajlpp::property_handler("impls") + .with_description("External opener implementations") + .with_children(opener_impls_handlers), +}; + static const struct json_path_container log_source_watch_expr_handlers = { yajlpp::property_handler("expr") .with_synopsis("") @@ -1531,6 +1573,9 @@ static const struct json_path_container tuning_handlers = { yajlpp::property_handler("clipboard") .with_description("Settings related to the clipboard") .with_children(sysclip_handlers), + yajlpp::property_handler("external-opener") + .with_description("Settings related to opening external files/URLs") + .with_children(opener_handlers), yajlpp::property_handler("url-scheme") .with_description("Settings related to custom URL handling") .with_children(url_handlers), diff --git a/src/lnav_config.hh b/src/lnav_config.hh index 24080066..4a6aba60 100644 --- a/src/lnav_config.hh +++ b/src/lnav_config.hh @@ -43,6 +43,7 @@ #include "base/file_range.hh" #include "base/lnav.console.hh" #include "base/result.h" +#include "external_opener.cfg.hh" #include "file_vtab.cfg.hh" #include "ghc/filesystem.hpp" #include "lnav_config_fwd.hh" @@ -128,6 +129,7 @@ struct _lnav_config { lnav::url_handler::config lc_url_handlers; logfile_sub_source_ns::config lc_log_source; lnav::log::annotate::config lc_log_annotations; + lnav::external_opener::config lc_opener; }; extern struct _lnav_config lnav_config; diff --git a/src/md2attr_line.cc b/src/md2attr_line.cc index 68935859..4da2e034 100644 --- a/src/md2attr_line.cc +++ b/src/md2attr_line.cc @@ -579,11 +579,11 @@ md2attr_line::leave_span(const md4cpp::event_handler::span& sp) static_cast(this->ml_span_starts.back()), static_cast(last_block.length()), }; + auto abs_href = this->append_url_footnote(href_str); last_block.with_attr({ lr, - VC_HYPERLINK.value(href_str), + VC_HYPERLINK.value(abs_href), }); - this->append_url_footnote(href_str); } else if (sp.is()) { const auto* img_detail = sp.get(); const auto src_str @@ -1052,7 +1052,7 @@ md2attr_line::text(MD_TEXTTYPE tt, const string_fragment& sf) return Ok(); } -void +std::string md2attr_line::append_url_footnote(std::string href_str) { auto is_internal = startswith(href_str, "#"); @@ -1065,7 +1065,7 @@ md2attr_line::append_url_footnote(std::string href_str) VC_STYLE.value(text_attrs{A_UNDERLINE}), }); if (is_internal) { - return; + return href_str; } if (this->ml_last_superscript_index == last_block.length()) { @@ -1085,4 +1085,6 @@ md2attr_line::append_url_footnote(std::string href_str) href.with_attr_for_all(VC_ROLE.value(role_t::VCR_FOOTNOTE_TEXT)); href.with_attr_for_all(SA_PREFORMATTED.value()); this->ml_footnotes.emplace_back(href); + + return href_str; } diff --git a/src/md2attr_line.hh b/src/md2attr_line.hh index 3c07c48e..809bcb20 100644 --- a/src/md2attr_line.hh +++ b/src/md2attr_line.hh @@ -79,7 +79,7 @@ private: using list_block_t = mapbox::util::variant; - void append_url_footnote(std::string href); + std::string append_url_footnote(std::string href); void flush_footnotes(); attr_line_t to_attr_line(const pugi::xml_node& doc); diff --git a/src/root-config.json b/src/root-config.json index 335e85be..8534581d 100644 --- a/src/root-config.json +++ b/src/root-config.json @@ -107,6 +107,18 @@ } } }, + "external-opener": { + "impls": { + "MacOS": { + "test": "command -v open", + "command": "open" + }, + "XDG": { + "test": "command -v xdg-open", + "command": "xdg-open" + } + } + }, "url-scheme": { "docker": { "handler": "docker-url-handler" diff --git a/src/scripts/lnav-copy-text.lnav b/src/scripts/lnav-copy-text.lnav index 541409e0..44b1938a 100644 --- a/src/scripts/lnav-copy-text.lnav +++ b/src/scripts/lnav-copy-text.lnav @@ -3,12 +3,17 @@ # @description: Copy text from the top view # -;SELECT jget(selected_text, '/value') AS content FROM lnav_top_view +;SELECT + jget(selected_text, '/value') AS sel_value, + jget(selected_text, '/href') AS sel_href + FROM lnav_top_view ;SELECT CASE - WHEN $content IS NULL THEN - ':write-to -' + WHEN $sel_href IS NOT NULL AND $sel_href != '' THEN + ':echo -n ${sel_href}' + WHEN $sel_value IS NOT NULL AND $sel_value != '' THEN + ':echo -n ${sel_value}' ELSE - ':echo -n ${content}' + ':write-to -' END AS cmd :redirect-to /dev/clipboard diff --git a/src/text_link_handler.cc b/src/text_link_handler.cc new file mode 100644 index 00000000..e7c322b2 --- /dev/null +++ b/src/text_link_handler.cc @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2024, Timothy Stack + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of Timothy Stack nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "text_link_handler.hh" + +#include + +#include "base/injector.hh" +#include "command_executor.hh" + +void +text_link_handler::text_open_href(const std::string& href) +{ + static auto& ec = injector::get(); + + log_info("open link: %s", href.c_str()); + if (startswith(href, "#")) { + auto* ta = dynamic_cast(this); + if (ta != nullptr) { + ta->row_for_anchor(href) | + [this](auto row) { this->tss_view->set_selection(row); }; + } + } else if (!is_url(href) || startswith(href, "file:")) { + ec.execute_with(":open $href", std::make_pair("href", href)); + } else { + this->tlh_hrefs.clear(); + this->tlh_hrefs.emplace(href); + this->tlh_href_line = this->tss_view->get_selection(); + } +} diff --git a/src/text_link_handler.hh b/src/text_link_handler.hh new file mode 100644 index 00000000..4eeda2cd --- /dev/null +++ b/src/text_link_handler.hh @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024, Timothy Stack + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of Timothy Stack nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ''AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef lnav_text_link_handler_hh +#define lnav_text_link_handler_hh + +#include "textview_curses.hh" + +class text_link_handler : public text_sub_source { +public: + void text_open_href(const std::string& href) override; + + std::optional tlh_href_line; + std::set tlh_hrefs; +}; + +#endif diff --git a/src/text_overlay_menu.cc b/src/text_overlay_menu.cc index d143e033..47b638e5 100644 --- a/src/text_overlay_menu.cc +++ b/src/text_overlay_menu.cc @@ -41,44 +41,79 @@ using namespace lnav::roles::literals; std::vector text_overlay_menu::list_overlay_menu(const listview_curses& lv, vis_line_t row) { + static const auto MENU_WIDTH = 25; + const auto* tc = dynamic_cast(&lv); std::vector retval; - if (!tc->tc_text_selection_active && tc->tc_selected_text) { - const auto& sti = tc->tc_selected_text.value(); + if (tc->tc_text_selection_active || !tc->tc_selected_text) { + return retval; + } - if (sti.sti_line == row) { - auto title = " Filter Other "_status_title; - auto left = std::max(0, sti.sti_x - 2); - auto dim = lv.get_dimensions(); + const auto& sti = tc->tc_selected_text.value(); - if (left + title.first.length() >= dim.second) { - left = dim.second - title.first.length() - 2; - } + if (sti.sti_line == row) { + auto title = " Actions "_status_title; + auto left = std::max(0, sti.sti_x - 2); + auto dim = lv.get_dimensions(); - this->tom_menu_items.clear(); - retval.emplace_back(attr_line_t().pad_to(left).append(title)); - { - attr_line_t al; + if (left + MENU_WIDTH >= dim.second) { + left = dim.second - MENU_WIDTH; + } - al.append(" ").append("\u2714 IN"_ok).append(" "); - int start = left; - this->tom_menu_items.emplace_back( - 1_vl, - line_range{start, start + (int) al.length()}, - [](const std::string& value) { - auto cmd = fmt::format(FMT_STRING(":filter-in {}"), - lnav::pcre2pp::quote(value)); - lnav_data.ld_exec_context - .with_provenance(exec_context::mouse_input{}) - ->execute(cmd); - }); - start += al.length(); - al.append(":mag_right:"_emoji) - .append(" Search ") - .with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS)); + this->tom_menu_items.clear(); + + auto is_link = !sti.sti_href.empty(); + auto menu_line = vis_line_t{1}; + if (is_link) { + auto ta = text_attrs{}; + + ta.ta_attrs |= A_UNDERLINE; + auto href_al + = attr_line_t(" Link: ") + .append(lnav::roles::table_header(sti.sti_href)) + .with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS_INFO)) + .with_attr_for_all(VC_STYLE.value(ta)); + retval.emplace_back(href_al); + menu_line += 1_vl; + } + + retval.emplace_back(attr_line_t().pad_to(left).append(title)); + { + attr_line_t al; + + int start = left; + if (is_link) { + al.append(":floppy_disk:"_emoji) + .append(" Open in lnav") + .append(" "); + } else { + al.append(" ").append("\u2714 Filter-in"_ok).append(" "); + } + this->tom_menu_items.emplace_back( + menu_line, + line_range{start, start + (int) al.length()}, + [is_link, sti](const std::string& value) { + auto cmd = is_link + ? ":open $href" + : fmt::format(FMT_STRING(":filter-in {}"), + lnav::pcre2pp::quote(value)); + lnav_data.ld_exec_context + .with_provenance(exec_context::mouse_input{}) + ->execute_with(cmd, + std::make_pair("href", sti.sti_href)); + }); + start += al.length(); + + if (is_link) { + al.append(" "); + } else { + al.append(":mag_right:"_emoji).append(" Search "); + } + al.with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS)); + if (!is_link) { this->tom_menu_items.emplace_back( - 1_vl, + menu_line, line_range{start, start + (int) al.length()}, [](const std::string& value) { auto cmd = fmt::format(FMT_STRING("/{}"), @@ -87,35 +122,43 @@ text_overlay_menu::list_overlay_menu(const listview_curses& lv, vis_line_t row) .with_provenance(exec_context::mouse_input{}) ->execute(cmd); }); - retval.emplace_back(attr_line_t().pad_to(left).append(al)); } - { - attr_line_t al; + retval.emplace_back(attr_line_t().pad_to(left).append(al)); + } + { + attr_line_t al; - al.append(" ").append("\u2718 OUT"_error).append(" "); - int start = left; - this->tom_menu_items.emplace_back( - 2_vl, - line_range{start, start + (int) al.length()}, - [](const std::string& value) { - auto cmd = fmt::format(FMT_STRING(":filter-out {}"), - lnav::pcre2pp::quote(value)); - lnav_data.ld_exec_context - .with_provenance(exec_context::mouse_input{}) - ->execute(cmd); - }); - start += al.length(); - al.append(":clipboard:"_emoji) - .append(" Copy ") - .with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS)); - this->tom_menu_items.emplace_back( - 2_vl, - line_range{start, start + (int) al.length()}, - [](const std::string& value) { - lnav_data.ld_exec_context.execute("|lnav-copy-text"); - }); - retval.emplace_back(attr_line_t().pad_to(left).append(al)); + if (is_link) { + al.append(":globe_with_meridians:"_emoji).append(" Open "); + } else { + al.append(" ").append("\u2718 Filter-out"_error).append(" "); } + menu_line += 1_vl; + int start = left; + this->tom_menu_items.emplace_back( + menu_line, + line_range{start, start + (int) al.length()}, + [is_link, sti](const std::string& value) { + auto cmd = is_link + ? ":xopen $href" + : fmt::format(FMT_STRING(":filter-out {}"), + lnav::pcre2pp::quote(value)); + lnav_data.ld_exec_context + .with_provenance(exec_context::mouse_input{}) + ->execute_with(cmd, + std::make_pair("href", sti.sti_href)); + }); + start += al.length(); + al.append(":clipboard:"_emoji) + .append(is_link ? " Copy link " : " Copy ") + .with_attr_for_all(VC_ROLE.value(role_t::VCR_STATUS)); + this->tom_menu_items.emplace_back( + menu_line, + line_range{start, start + (int) al.length()}, + [](const std::string& value) { + lnav_data.ld_exec_context.execute("|lnav-copy-text"); + }); + retval.emplace_back(attr_line_t().pad_to(left).append(al)); } } diff --git a/src/textfile_sub_source.cc b/src/textfile_sub_source.cc index b99a1137..8a5030e8 100644 --- a/src/textfile_sub_source.cc +++ b/src/textfile_sub_source.cc @@ -316,19 +316,17 @@ textfile_sub_source::text_size_for_line(textview_curses& tc, void textfile_sub_source::to_front(const std::shared_ptr& lf) { - auto iter = std::find(this->tss_files.begin(), this->tss_files.end(), lf); - if (iter != this->tss_files.end()) { - this->tss_files.erase(iter); - } else { - iter = std::find( - this->tss_hidden_files.begin(), this->tss_hidden_files.end(), lf); - - if (iter != this->tss_hidden_files.end()) { - this->tss_hidden_files.erase(iter); - } + const auto iter + = std::find(this->tss_files.begin(), this->tss_files.end(), lf); + if (iter == this->tss_files.end()) { + return; } - this->tss_files.push_front(lf); + this->tss_files.front().save_from(*this->tss_view); + auto fvs = *iter; + this->tss_files.erase(iter); + this->tss_files.emplace_front(fvs); this->set_time_offset(false); + fvs.load_into(*this->tss_view); this->tss_view->reload_data(); } @@ -338,6 +336,8 @@ textfile_sub_source::rotate_left() if (this->tss_files.size() > 1) { this->tss_files.push_back(this->tss_files.front()); this->tss_files.pop_front(); + this->tss_files.back().save_from(*this->tss_view); + this->tss_files.front().load_into(*this->tss_view); this->set_time_offset(false); this->tss_view->reload_data(); this->tss_view->redo_search(); @@ -348,8 +348,10 @@ void textfile_sub_source::rotate_right() { if (this->tss_files.size() > 1) { - this->tss_files.push_front(this->tss_files.back()); + this->tss_files.front().save_from(*this->tss_view); + this->tss_files.emplace_front(this->tss_files.back()); this->tss_files.pop_back(); + this->tss_files.front().load_into(*this->tss_view); this->set_time_offset(false); this->tss_view->reload_data(); this->tss_view->redo_search(); @@ -362,16 +364,13 @@ textfile_sub_source::remove(const std::shared_ptr& lf) auto iter = std::find(this->tss_files.begin(), this->tss_files.end(), lf); if (iter != this->tss_files.end()) { this->tss_files.erase(iter); - detach_observer(lf); - } else { - iter = std::find( - this->tss_hidden_files.begin(), this->tss_hidden_files.end(), lf); - if (iter != this->tss_hidden_files.end()) { - this->tss_hidden_files.erase(iter); - detach_observer(lf); - } + this->detach_observer(lf); } this->set_time_offset(false); + if (!this->tss_files.empty()) { + this->tss_files.front().load_into(*this->tss_view); + } + this->tss_view->reload_data(); } void @@ -379,7 +378,7 @@ textfile_sub_source::push_back(const std::shared_ptr& lf) { auto* lfo = new line_filter_observer(this->get_filters(), lf); lf->set_logline_observer(lfo); - this->tss_files.push_back(lf); + this->tss_files.emplace_back(lf); } void @@ -449,7 +448,7 @@ textfile_sub_source::scroll_invoked(textview_curses* tc) int textfile_sub_source::get_filtered_count() const { - std::shared_ptr lf = this->current_file(); + auto lf = this->current_file(); int retval = 0; if (lf != nullptr) { @@ -482,7 +481,7 @@ textfile_sub_source::get_text_format() const return text_format_t::TF_UNKNOWN; } - return this->tss_files.front()->get_text_format(); + return this->tss_files.front().fvs_file->get_text_format(); } static attr_line_t @@ -517,8 +516,8 @@ textfile_sub_source::text_crumbs_for_line( [this]() { return this->tss_files | lnav::itertools::map([](const auto& lf) { return breadcrumb::possibility{ - lf->get_unique_path(), - to_display(lf), + lf.fvs_file->get_unique_path(), + to_display(lf.fvs_file), }; }); }, @@ -526,7 +525,7 @@ textfile_sub_source::text_crumbs_for_line( auto lf_opt = this->tss_files | lnav::itertools::find_if([&key](const auto& elem) { return key.template get() - == elem->get_unique_path(); + == elem.fvs_file->get_unique_path(); }) | lnav::itertools::deref(); @@ -534,7 +533,7 @@ textfile_sub_source::text_crumbs_for_line( return; } - this->to_front(lf_opt.value()); + this->to_front(lf_opt.value().fvs_file); this->tss_view->reload_data(); }); if (lf->size() == 0) { @@ -686,9 +685,8 @@ textfile_sub_source::text_crumbs_for_line( } textfile_sub_source::rescan_result_t -textfile_sub_source::rescan_files( - textfile_sub_source::scan_callback& callback, - std::optional deadline) +textfile_sub_source::rescan_files(textfile_sub_source::scan_callback& callback, + std::optional deadline) { static auto& lnav_db = injector::get(); @@ -709,7 +707,7 @@ textfile_sub_source::rescan_files( break; } - std::shared_ptr lf = (*iter); + std::shared_ptr lf = iter->fvs_file; if (lf->is_closed()) { iter = this->tss_files.erase(iter); @@ -971,6 +969,10 @@ textfile_sub_source::rescan_files( } if (!closed_files.empty()) { callback.closed_files(closed_files); + if (!this->tss_files.empty()) { + this->tss_files.front().load_into(*this->tss_view); + } + this->tss_view->set_needs_update(); } if (retval.rr_new_data) { @@ -1005,7 +1007,7 @@ void textfile_sub_source::quiesce() { for (auto& lf : this->tss_files) { - lf->quiesce(); + lf.fvs_file->quiesce(); } } @@ -1355,20 +1357,13 @@ textfile_sub_source::to_front(const std::string& filename) { auto lf_opt = this->tss_files | lnav::itertools::find_if([&filename](const auto& elem) { - return elem->get_filename() == filename; + return elem.fvs_file->get_filename() == filename; }); - if (!lf_opt) { - lf_opt = this->tss_hidden_files - | lnav::itertools::find_if([&filename](const auto& elem) { - return elem->get_filename() == filename; - }); - } - if (!lf_opt) { return false; } - this->to_front(*(lf_opt.value())); + this->to_front(lf_opt.value()->fvs_file); return true; } @@ -1381,7 +1376,8 @@ textfile_sub_source::text_accel_get_line(vis_line_t vl) return (lf->begin() + lfo->lfo_filter_state.tfs_index[vl]).base(); } -textfile_header_overlay::textfile_header_overlay(textfile_sub_source* src) +textfile_header_overlay:: +textfile_header_overlay(textfile_sub_source* src) : tho_src(src) { } diff --git a/src/textfile_sub_source.hh b/src/textfile_sub_source.hh index 7f66555b..51a879cc 100644 --- a/src/textfile_sub_source.hh +++ b/src/textfile_sub_source.hh @@ -36,17 +36,16 @@ #include "filter_observer.hh" #include "logfile.hh" #include "plain_text_source.hh" +#include "text_link_handler.hh" #include "text_overlay_menu.hh" #include "textview_curses.hh" class textfile_sub_source - : public text_sub_source + : public text_link_handler , public vis_location_history , public text_accel_source , public text_anchors { public: - using file_iterator = std::deque>::iterator; - textfile_sub_source() { this->tss_supports_filtering = true; } bool empty() const { return this->tss_files.empty(); } @@ -81,7 +80,7 @@ public: return nullptr; } - return this->tss_files.front(); + return this->tss_files.front().fvs_file; } std::string text_source_name(const textview_curses& tv) override @@ -90,7 +89,7 @@ public: return ""; } - return this->tss_files.front()->get_filename(); + return this->tss_files.front().fvs_file->get_filename(); } void to_front(const std::shared_ptr& lf); @@ -149,7 +148,7 @@ public: std::optional anchor_for_row(vis_line_t vl) override; std::optional adjacent_anchor(vis_line_t vl, - direction dir) override; + direction dir) override; std::unordered_set get_anchors() override; @@ -177,6 +176,34 @@ private: delete lfo; } + struct file_view_state { + explicit file_view_state(const std::shared_ptr& f) + : fvs_file(f) + { + } + + bool operator==(const std::shared_ptr& lf) const + { + return this->fvs_file == lf; + } + + void save_from(const textview_curses& tc) + { + this->fvs_top = tc.get_top(); + this->fvs_selection = tc.get_selection(); + } + + void load_into(textview_curses& tc) const + { + tc.set_selection(this->fvs_selection); + tc.set_top(this->fvs_top); + } + + std::shared_ptr fvs_file; + vis_line_t fvs_top{0}; + vis_line_t fvs_selection{0}; + }; + struct rendered_file { time_t rf_mtime; file_ssize_t rf_file_size; @@ -189,8 +216,9 @@ private: lnav::document::metadata ms_metadata; }; - std::deque> tss_files; - std::deque> tss_hidden_files; + using file_iterator = std::deque::iterator; + + std::deque tss_files; std::unordered_map tss_rendered_files; std::unordered_map tss_doc_metadata; size_t tss_line_indent_size{0}; diff --git a/src/textview_curses.cc b/src/textview_curses.cc index baf187bd..bfaab57e 100644 --- a/src/textview_curses.cc +++ b/src/textview_curses.cc @@ -628,11 +628,9 @@ textview_curses::handle_mouse(mouse_event& me) } else { if (this->tc_press_line.is()) { if (me.me_y < 0) { - this->shift_selection( - listview_curses::shift_amount_t::up_line); + this->shift_selection(shift_amount_t::up_line); } else if (me.me_y >= height) { - this->shift_selection( - listview_curses::shift_amount_t::down_line); + this->shift_selection(shift_amount_t::down_line); } else if (mouse_line.is()) { this->set_selection_without_context( mouse_line.get().mc_line); @@ -658,12 +656,12 @@ textview_curses::handle_mouse(mouse_event& me) } case mouse_button_state_t::BUTTON_STATE_RELEASED: { auto* ov = this->get_overlay_source(); - if (ov != nullptr && mouse_line.is() + if (ov != nullptr && mouse_line.is() && this->tc_selected_text) { auto* tom = dynamic_cast(ov); if (tom != nullptr) { - auto& om = mouse_line.get(); + auto& om = mouse_line.get(); auto& sti = this->tc_selected_text.value(); for (const auto& mi : tom->tom_menu_items) { @@ -701,20 +699,21 @@ textview_curses::handle_mouse(mouse_event& me) attr_line_t al; this->textview_value_for_row(mc_line, al); - auto get_res = get_string_attr(al.get_attrs(), - &VC_HYPERLINK, - this->lv_left + me.me_press_x); - if (get_res) { - auto href = get_res.value()->sa_value.get(); - - if (startswith(href, "#")) { - auto* ta - = dynamic_cast(this->tc_sub_source); - if (ta != nullptr) { - ta->row_for_anchor(href) | - [this](auto row) { this->set_selection(row); }; - } - } + auto line_sf = string_fragment::from_str(al.get_string()); + auto cursor_sf = line_sf.sub_cell_range( + this->lv_left + me.me_x, this->lv_left + me.me_x); + auto attr_iter = find_string_attr_containing( + al.get_attrs(), &VC_HYPERLINK, cursor_sf.sf_begin); + if (attr_iter != al.get_attrs().end()) { + auto href = attr_iter->sa_value.get(); + + this->tc_selected_text = selected_text_info{ + me.me_x, + mc_line, + attr_iter->sa_range, + al.to_string_fragment(attr_iter).to_string(), + href, + }; } } if (this->tc_delegate != nullptr) { diff --git a/src/textview_curses.hh b/src/textview_curses.hh index f3a0821c..c7a2880c 100644 --- a/src/textview_curses.hh +++ b/src/textview_curses.hh @@ -509,6 +509,8 @@ public: virtual void scroll_invoked(textview_curses* tc); + virtual void text_open_href(const std::string& href) {} + bool tss_supports_filtering{false}; bool tss_apply_filters{true}; @@ -808,6 +810,7 @@ public: int64_t sti_line; line_range sti_range; std::string sti_value; + std::string sti_href; }; std::optional tc_selected_text; diff --git a/src/url_handler.cc b/src/url_handler.cc index a9092536..eb8824a2 100644 --- a/src/url_handler.cc +++ b/src/url_handler.cc @@ -125,7 +125,7 @@ looper::open(std::string url) return Err(lnav::console::user_message::error( attr_line_t("cannot get scheme from URL: ") .append(lnav::roles::file(url))) - .with_reason(curl_url_strerror(set_rc))); + .with_reason(curl_url_strerror(get_rc))); } auto proto_iter = cfg.c_schemes.find(scheme_part); diff --git a/src/views_vtab.cc b/src/views_vtab.cc index 0412f1f3..75f44c77 100644 --- a/src/views_vtab.cc +++ b/src/views_vtab.cc @@ -189,6 +189,8 @@ static const typed_json_path_container .with_children(line_range_handlers), yajlpp::property_handler("value").for_field( &textview_curses::selected_text_info::sti_value), + yajlpp::property_handler("href").for_field( + &textview_curses::selected_text_info::sti_href), }; enum class row_details_t { @@ -411,8 +413,7 @@ CREATE TABLE lnav_views ( | [](const auto wrapper) { auto lf = wrapper.get(); - return std::make_optional( - lf->get_filename()); + return std::make_optional(lf->get_filename()); }; }); for (const auto& crumb : crumbs) { diff --git a/test/expected/test_cli.sh_0b3639753916f71254e8c9cce4ebb8bfd9978d3e.out b/test/expected/test_cli.sh_0b3639753916f71254e8c9cce4ebb8bfd9978d3e.out index 7fb5d8c3..43c55f58 100644 --- a/test/expected/test_cli.sh_0b3639753916f71254e8c9cce4ebb8bfd9978d3e.out +++ b/test/expected/test_cli.sh_0b3639753916f71254e8c9cce4ebb8bfd9978d3e.out @@ -101,6 +101,18 @@ } } }, + "external-opener": { + "impls": { + "MacOS": { + "test": "command -v open", + "command": "open" + }, + "XDG": { + "test": "command -v xdg-open", + "command": "xdg-open" + } + } + }, "url-scheme": { "docker": { "handler": "docker-url-handler" diff --git a/test/expected/test_cli.sh_cc06341dd560f927512e92c7c0985ed8b25827ae.out b/test/expected/test_cli.sh_cc06341dd560f927512e92c7c0985ed8b25827ae.out index 9a951281..dc3277fe 100644 --- a/test/expected/test_cli.sh_cc06341dd560f927512e92c7c0985ed8b25827ae.out +++ b/test/expected/test_cli.sh_cc06341dd560f927512e92c7c0985ed8b25827ae.out @@ -44,6 +44,10 @@ /tuning/clipboard/impls/tmux/general/read -> root-config.json:92 /tuning/clipboard/impls/tmux/general/write -> root-config.json:91 /tuning/clipboard/impls/tmux/test -> root-config.json:89 +/tuning/external-opener/impls/MacOS/command -> root-config.json:114 +/tuning/external-opener/impls/MacOS/test -> root-config.json:113 +/tuning/external-opener/impls/XDG/command -> root-config.json:118 +/tuning/external-opener/impls/XDG/test -> root-config.json:117 /tuning/piper/max-size -> root-config.json:57 /tuning/piper/rotations -> root-config.json:58 /tuning/piper/ttl -> root-config.json:59 @@ -52,12 +56,12 @@ /tuning/remote/ssh/config/ConnectTimeout -> root-config.json:50 /tuning/remote/ssh/start-command -> root-config.json:52 /tuning/remote/ssh/transfer-command -> root-config.json:53 -/tuning/url-scheme/docker-compose/handler -> root-config.json:115 -/tuning/url-scheme/docker/handler -> root-config.json:112 +/tuning/url-scheme/docker-compose/handler -> root-config.json:127 +/tuning/url-scheme/docker/handler -> root-config.json:124 /tuning/url-scheme/hw/handler -> {test_dir}/configs/installed/hw-url-handler.json:6 -/tuning/url-scheme/journald/handler -> root-config.json:118 -/tuning/url-scheme/piper/handler -> root-config.json:121 -/tuning/url-scheme/podman/handler -> root-config.json:124 +/tuning/url-scheme/journald/handler -> root-config.json:130 +/tuning/url-scheme/piper/handler -> root-config.json:133 +/tuning/url-scheme/podman/handler -> root-config.json:136 /ui/clock-format -> root-config.json:4 /ui/default-colors -> root-config.json:6 /ui/dim-text -> root-config.json:5 diff --git a/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out b/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out index 71170643..db935fc0 100644 --- a/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out +++ b/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out @@ -1890,6 +1890,18 @@ For support questions, email: +:xopen path1 [... pathN] +══════════════════════════════════════════════════════════════════════ + Use an external command to open the given file(s) +Parameter + path The path to the file to open + +Example +#1 To open the file '/path/to/file': + :xopen /path/to/file  + + + :zoom-to zoom-level ══════════════════════════════════════════════════════════════════════ Zoom the histogram view to the given level diff --git a/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out b/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out index 3f4748f6..e59efec0 100644 --- a/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out +++ b/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out @@ -61,7 +61,7 @@ The following screenshot shows a mix of syslog and web access log files. Failed requests are shown in red. Identifiers, like IP address and PIDs are semantically highlighted. -]8;;docs/assets/images/lnav-front-page.png\🖼 Screenshot]8;;\]8;;docs/assets/images/lnav-front-page.png\¹]8;;\˒² +]8;;file://{top_srcdir}/docs/assets/images/lnav-front-page.png\🖼 Screenshot]8;;\]8;;file://{top_srcdir}/docs/assets/images/lnav-front-page.png\¹]8;;\˒² ▌[1] - file://{top_srcdir}/docs/assets/images/lnav-front-page.png ▌[2] - file://{top_srcdir}/docs/assets/images/lnav-front-page.png @@ -185,7 +185,7 @@ The following alternatives are also available: • ]8;;https://lnav.org\Main Site]8;;\¹ • ]8;;https://docs.lnav.org\Documentation]8;;\² on Read the Docs - • ]8;;ARCHITECTURE.md\Internal Architecture]8;;\³ + • ]8;;file://{top_srcdir}/ARCHITECTURE.md\Internal Architecture]8;;\³ ▌[1] - https://lnav.org ▌[2] - https://docs.lnav.org diff --git a/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out b/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out index a9d7df50..8bfa0cc4 100644 --- a/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out +++ b/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out @@ -4,7 +4,7 @@ The following screenshot shows a mix of syslog and web access log files. Failed requests are shown in red. Identifiers, like IP address and PIDs are semantically highlighted. -]8;;docs/assets/images/lnav-front-page.png\🖼 Screenshot]8;;\]8;;docs/assets/images/lnav-front-page.png\¹]8;;\˒² +]8;;file://{top_srcdir}/docs/assets/images/lnav-front-page.png\🖼 Screenshot]8;;\]8;;file://{top_srcdir}/docs/assets/images/lnav-front-page.png\¹]8;;\˒² ▌[1] - file://{top_srcdir}/docs/assets/images/lnav-front-page.png ▌[2] - file://{top_srcdir}/docs/assets/images/lnav-front-page.png @@ -128,7 +128,7 @@ The following alternatives are also available: • ]8;;https://lnav.org\Main Site]8;;\¹ • ]8;;https://docs.lnav.org\Documentation]8;;\² on Read the Docs - • ]8;;ARCHITECTURE.md\Internal Architecture]8;;\³ + • ]8;;file://{top_srcdir}/ARCHITECTURE.md\Internal Architecture]8;;\³ ▌[1] - https://lnav.org ▌[2] - https://docs.lnav.org diff --git a/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out b/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out index a9d7df50..8bfa0cc4 100644 --- a/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out +++ b/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out @@ -4,7 +4,7 @@ The following screenshot shows a mix of syslog and web access log files. Failed requests are shown in red. Identifiers, like IP address and PIDs are semantically highlighted. -]8;;docs/assets/images/lnav-front-page.png\🖼 Screenshot]8;;\]8;;docs/assets/images/lnav-front-page.png\¹]8;;\˒² +]8;;file://{top_srcdir}/docs/assets/images/lnav-front-page.png\🖼 Screenshot]8;;\]8;;file://{top_srcdir}/docs/assets/images/lnav-front-page.png\¹]8;;\˒² ▌[1] - file://{top_srcdir}/docs/assets/images/lnav-front-page.png ▌[2] - file://{top_srcdir}/docs/assets/images/lnav-front-page.png @@ -128,7 +128,7 @@ The following alternatives are also available: • ]8;;https://lnav.org\Main Site]8;;\¹ • ]8;;https://docs.lnav.org\Documentation]8;;\² on Read the Docs - • ]8;;ARCHITECTURE.md\Internal Architecture]8;;\³ + • ]8;;file://{top_srcdir}/ARCHITECTURE.md\Internal Architecture]8;;\³ ▌[1] - https://lnav.org ▌[2] - https://docs.lnav.org