diff --git a/src/application.cpp b/src/application.cpp index e6abbd6..4738c64 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -157,7 +157,10 @@ void GitApp::tree(std::string name, std::string path) { c.path = clean; c.filename = util::basename(clean); c.is_binary = is_binary; - c.is_markdown = !is_binary && util::is_markdown_path(clean); + c.is_image = !util::image_mime_type(clean).empty(); + c.is_markdown = !is_binary && !c.is_image && util::is_markdown_path(clean); + // Binary images carry no displayable source; text images (SVG) keep + // their source so it can be shown below the preview. c.text = is_binary ? "" : text; c.size = text.size(); render(SKIN, "file_view", c); @@ -185,14 +188,19 @@ void GitApp::raw(std::string name, std::string path) { return; try { const std::string commit = commit_from_request(*repo); + const std::string clean = util::trim_slashes(path); std::string content; bool is_binary = false; - if (!repo->read_file(commit, util::trim_slashes(path), content, is_binary)) { + if (!repo->read_file(commit, clean, content, is_binary)) { show_error(404, "No such file: " + path); return; } - response().content_type(is_binary ? "application/octet-stream" - : "text/plain; charset=utf-8"); + const std::string image_mime = util::image_mime_type(clean); + if (!image_mime.empty()) + response().content_type(image_mime); + else + response().content_type(is_binary ? "application/octet-stream" + : "text/plain; charset=utf-8"); response().out().write(content.data(), static_cast(content.size())); } catch (const GitError &e) { show_error(404, e.what()); diff --git a/src/content.h b/src/content.h index 2a48b03..4b227ee 100644 --- a/src/content.h +++ b/src/content.h @@ -48,6 +48,7 @@ struct file_view : public master { std::string text; bool is_binary = false; bool is_markdown = false; + bool is_image = false; uint64_t size = 0; }; diff --git a/src/git_repo.cpp b/src/git_repo.cpp index 0d0eca5..8163583 100644 --- a/src/git_repo.cpp +++ b/src/git_repo.cpp @@ -77,6 +77,38 @@ TreeEntry make_entry(const git_tree_entry *entry, const std::string &dir, return out; } +// Upper bound on folded path depth; guards against absurdly deep chains. +const int MAX_COLLAPSE_DEPTH = 64; + +// Fold a chain of single-child directories into one entry, e.g. a "com" folder +// holding only "domain" holding only "package" becomes "com/domain/package" so +// navigation jumps straight to the deepest folder. Stops as soon as a directory +// has more than one child or its only child is not itself a directory. Costs one +// tree lookup per directory shown in a listing. +void collapse_single_child_dirs(git_repository *repo, TreeEntry &entry) { + git_oid current; + if (git_oid_fromstr(¤t, entry.oid.c_str()) != 0) + return; + for (int depth = 0; depth < MAX_COLLAPSE_DEPTH; ++depth) { + git_tree *tree = nullptr; + if (git_tree_lookup(&tree, repo, ¤t) != 0) + return; + const git_tree_entry *child = + git_tree_entrycount(tree) == 1 ? git_tree_entry_byindex(tree, 0) : nullptr; + if (!child || git_tree_entry_type(child) != GIT_OBJECT_TREE) { + git_tree_free(tree); + return; + } + entry.name += "/"; + entry.name += git_tree_entry_name(child); + entry.path += "/"; + entry.path += git_tree_entry_name(child); + git_oid_cpy(¤t, git_tree_entry_id(child)); + entry.oid = oid_to_string(¤t); + git_tree_free(tree); + } +} + std::string first_line(const char *text) { if (!text) return ""; @@ -216,8 +248,12 @@ std::vector GitRepo::list_tree(const std::string &commit_oid, std::vector entries; const size_t count = git_tree_entrycount(tree); entries.reserve(count); - for (size_t i = 0; i < count; ++i) - entries.push_back(make_entry(git_tree_entry_byindex(tree, i), clean, repo_)); + for (size_t i = 0; i < count; ++i) { + TreeEntry e = make_entry(git_tree_entry_byindex(tree, i), clean, repo_); + if (e.is_dir) + collapse_single_child_dirs(repo_, e); + entries.push_back(std::move(e)); + } if (subtree) git_tree_free(subtree); diff --git a/src/util.cpp b/src/util.cpp index 121cdcb..273b6a3 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -82,6 +82,31 @@ bool is_markdown_path(const std::string &path) { return ext == "md" || ext == "markdown"; } +std::string image_mime_type(const std::string &path) { + const size_t dot = path.find_last_of('.'); + if (dot == std::string::npos) + return ""; + std::string ext = path.substr(dot + 1); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + if (ext == "png") + return "image/png"; + if (ext == "jpg" || ext == "jpeg") + return "image/jpeg"; + if (ext == "gif") + return "image/gif"; + if (ext == "webp") + return "image/webp"; + if (ext == "svg") + return "image/svg+xml"; + if (ext == "bmp") + return "image/bmp"; + if (ext == "ico") + return "image/x-icon"; + if (ext == "avif") + return "image/avif"; + return ""; +} + bool show_emails() { static const bool value = [] { const char *flag = std::getenv("SHOW_EMAILS"); diff --git a/src/util.h b/src/util.h index 8ccd1d3..d395040 100644 --- a/src/util.h +++ b/src/util.h @@ -33,4 +33,8 @@ bool show_emails(); // insensitive. bool is_markdown_path(const std::string &path); +// MIME type for a recognised image path by extension (e.g. "image/png"), or +// "" when the path is not a recognised image. Case insensitive. +std::string image_mime_type(const std::string &path); + } // namespace util diff --git a/static/app.js b/static/app.js index ea169a4..5cb7ef3 100644 --- a/static/app.js +++ b/static/app.js @@ -3,10 +3,39 @@ (function () { "use strict"; + // Map a filename to an explicit highlight.js language. Full-name matches + // (e.g. CMakeLists.txt) take priority over extensions. + var FILENAME_LANGUAGES = { "CMakeLists.txt": "cmake" }; + var EXTENSION_LANGUAGES = { + cpp: "cpp", + hpp: "cpp", + js: "javascript", + json: "json", + yml: "yaml", + txt: "plaintext", + }; + + function languageForFilename(filename) { + if (!filename) return null; + if (FILENAME_LANGUAGES[filename]) return FILENAME_LANGUAGES[filename]; + var dot = filename.lastIndexOf("."); + // Extensionless files (LICENSE, README, etc.) are treated as plain text + // rather than letting auto-detection guess a language. + if (dot === -1) return "plaintext"; + return EXTENSION_LANGUAGES[filename.slice(dot + 1).toLowerCase()] || null; + } + function highlight() { if (typeof hljs === "undefined") return; document.querySelectorAll("pre.code > code.hljs").forEach(function (el) { + var lang = languageForFilename(el.dataset.filename); + if (lang) el.classList.add("language-" + lang); hljs.highlightElement(el); + var detected = el.result && el.result.language ? el.result.language : "plaintext"; + var label = document.createElement("p"); + label.className = "lang-detected"; + label.textContent = "Detected language: " + detected; + el.parentElement.insertAdjacentElement("afterend", label); }); } diff --git a/static/cmake.min.js b/static/cmake.min.js new file mode 100644 index 0000000..7eae4c3 --- /dev/null +++ b/static/cmake.min.js @@ -0,0 +1,7 @@ +/*! `cmake` grammar compiled for Highlight.js 11.9.0 */ +(()=>{var e=(()=>{"use strict";return e=>({name:"CMake",aliases:["cmake.in"], +case_insensitive:!0,keywords:{ +keyword:"break cmake_host_system_information cmake_minimum_required cmake_parse_arguments cmake_policy configure_file continue elseif else endforeach endfunction endif endmacro endwhile execute_process file find_file find_library find_package find_path find_program foreach function get_cmake_property get_directory_property get_filename_component get_property if include include_guard list macro mark_as_advanced math message option return separate_arguments set_directory_properties set_property set site_name string unset variable_watch while add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory build_command create_test_sourcelist define_property enable_language enable_testing export fltk_wrap_ui get_source_file_property get_target_property get_test_property include_directories include_external_msproject include_regular_expression install link_directories link_libraries load_cache project qt_wrap_cpp qt_wrap_ui remove_definitions set_source_files_properties set_target_properties set_tests_properties source_group target_compile_definitions target_compile_features target_compile_options target_include_directories target_link_directories target_link_libraries target_link_options target_sources try_compile try_run ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload build_name exec_program export_library_dependencies install_files install_programs install_targets load_command make_directory output_required_files remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file qt5_use_modules qt5_use_package qt5_wrap_cpp on off true false and or not command policy target test exists is_newer_than is_directory is_symlink is_absolute matches less greater equal less_equal greater_equal strless strgreater strequal strless_equal strgreater_equal version_less version_greater version_equal version_less_equal version_greater_equal in_list defined" +},contains:[{className:"variable",begin:/\$\{/,end:/\}/ +},e.COMMENT(/#\[\[/,/]]/),e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE] +})})();hljs.registerLanguage("cmake",e)})(); \ No newline at end of file diff --git a/static/style.css b/static/style.css index fa3ccff..8b59db0 100644 --- a/static/style.css +++ b/static/style.css @@ -76,6 +76,8 @@ table.blame .line pre { margin: 0; white-space: pre; } .log .msg { width: 100%; } .binary { color: var(--muted); } +.lang-detected { color: var(--muted); font-size: 0.8rem; margin-top: 0.25rem; } +.fileimg img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--code-bg); } table.tree .status { font-family: ui-monospace, monospace; color: var(--muted); width: 1.5rem; } table.tree .before { margin-left: 0.5rem; font-size: 0.8rem; color: var(--muted); } diff --git a/templates/skin.tmpl b/templates/skin.tmpl index ac2b676..e5dfd08 100644 --- a/templates/skin.tmpl +++ b/templates/skin.tmpl @@ -28,6 +28,7 @@
<% include content_body() %>
+ @@ -110,6 +111,12 @@ blame · history

+<% if is_image %> +

<%= filename %>

+<% if not empty text %> +
<%= text %>
+<% end %> +<% else %> <% if is_binary %>

Binary file (<% c++ out() << util::human_size(content.size); %>) — view raw

@@ -121,6 +128,7 @@
<%= text %>
<% end %> <% end %> +<% end %> <% end template %> <% end view %>