Function history on the file page 65be9f9c3074
tree-sitter finds the functions and methods of Rust, TypeScript, Python
and shell files, qualified by their impl, trait or class (Repo::log), and
the file page's sidebar lists them. Each links to
/{owner}/{name}/function/{ref}/{path}?start=…&end=…&name=…: every change
that wrote or deleted one of the function's lines, or deleted one from
between them (oxy-core's line history, as oxy log -L), newest first, with
a graph of how they depend on each other. Exact, since lines keep their
identity; changes elsewhere in the file are not listed.
The pipeline pins the build image that vendors the tree-sitter crates.
closes #34
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 06:37 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
65be9f9c3074c724caeeb1e398643a1ee538441f7a91742aaaa1baad55a27bb4- Depends on
- 145306044896 380996db84e5 3f4b0ea6eabf 6ae41081ccd3 736f6813543d 760a7dd30d6f 93be9ce4653e 9e5ef6c23006 a22ff236b65a b64e2537d702 d23464c0ac60
- Depended on by
- c8684d44d549 0b203bbf7cc3 d1c9e8e3273d cc19697f8871 92574625ee9a
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 20 atoms, 2 tree operations, 13 files
Browse the tree at this change
added crates/hub/src/repos/functions.rs +112 −0
| 1 | + | //! Function boundaries on file pages (hub spec §6.6): tree-sitter finds |
|
| 2 | + | //! the functions and methods of a Rust, TypeScript, Python or shell file, |
|
| 3 | + | //! with the lines they span, so a function's history (Oxygen spec §27) is |
|
| 4 | + | //! one click away. |
|
| 5 | + | ||
| 6 | + | /// A function or method and the lines it spans (1-based, inclusive). |
|
| 7 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 8 | + | pub struct Function { |
|
| 9 | + | /// Qualified by its enclosing type or class where there is one: `Repo::log`. |
|
| 10 | + | pub name: String, |
|
| 11 | + | pub start: usize, |
|
| 12 | + | pub end: usize, |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | /// The grammar for a path, by its extension, and the node kinds that are |
|
| 16 | + | /// functions in it. |
|
| 17 | + | fn grammar(path: &str) -> Option<(tree_sitter::Language, &'static [&'static str])> { |
|
| 18 | + | let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; |
|
| 19 | + | Some(match ext.as_str() { |
|
| 20 | + | "rs" => (tree_sitter_rust::LANGUAGE.into(), &["function_item"][..]), |
|
| 21 | + | "ts" | "mts" | "cts" => ( |
|
| 22 | + | tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), |
|
| 23 | + | &["function_declaration", "method_definition", "generator_function_declaration"][..], |
|
| 24 | + | ), |
|
| 25 | + | "tsx" => ( |
|
| 26 | + | tree_sitter_typescript::LANGUAGE_TSX.into(), |
|
| 27 | + | &["function_declaration", "method_definition", "generator_function_declaration"][..], |
|
| 28 | + | ), |
|
| 29 | + | "py" => (tree_sitter_python::LANGUAGE.into(), &["function_definition"][..]), |
|
| 30 | + | "sh" | "bash" => (tree_sitter_bash::LANGUAGE.into(), &["function_definition"][..]), |
|
| 31 | + | _ => return None, |
|
| 32 | + | }) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// Node kinds whose name qualifies the functions inside them. |
|
| 36 | + | const CONTAINERS: &[&str] = &["impl_item", "trait_item", "class_declaration", "class_definition", "mod_item"]; |
|
| 37 | + | ||
| 38 | + | /// The functions of `text`, in file order; empty for a language without a |
|
| 39 | + | /// grammar here or text that does not parse. |
|
| 40 | + | pub fn functions(path: &str, text: &str) -> Vec<Function> { |
|
| 41 | + | let Some((language, kinds)) = grammar(path) else { return vec![] }; |
|
| 42 | + | let mut parser = tree_sitter::Parser::new(); |
|
| 43 | + | if parser.set_language(&language).is_err() { |
|
| 44 | + | return vec![]; |
|
| 45 | + | } |
|
| 46 | + | let Some(tree) = parser.parse(text, None) else { return vec![] }; |
|
| 47 | + | let mut out = Vec::new(); |
|
| 48 | + | walk(tree.root_node(), text.as_bytes(), kinds, &mut Vec::new(), &mut out); |
|
| 49 | + | out |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | fn name_of(node: tree_sitter::Node, src: &[u8]) -> Option<String> { |
|
| 53 | + | let n = node.child_by_field_name("name").or_else(|| node.child_by_field_name("type"))?; |
|
| 54 | + | n.utf8_text(src).ok().map(|s| s.split_whitespace().collect::<Vec<_>>().join(" ")) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | fn walk(node: tree_sitter::Node, src: &[u8], kinds: &[&str], scope: &mut Vec<String>, out: &mut Vec<Function>) { |
|
| 58 | + | let kind = node.kind(); |
|
| 59 | + | let mut pushed = false; |
|
| 60 | + | if kinds.contains(&kind) { |
|
| 61 | + | if let Some(name) = name_of(node, src) { |
|
| 62 | + | let mut full = scope.clone(); |
|
| 63 | + | full.push(name); |
|
| 64 | + | out.push(Function { name: full.join("::"), start: node.start_position().row + 1, end: node.end_position().row + 1 }); |
|
| 65 | + | } |
|
| 66 | + | } else if CONTAINERS.contains(&kind) { |
|
| 67 | + | if let Some(name) = name_of(node, src) { |
|
| 68 | + | scope.push(name); |
|
| 69 | + | pushed = true; |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | let mut cursor = node.walk(); |
|
| 73 | + | for child in node.children(&mut cursor) { |
|
| 74 | + | walk(child, src, kinds, scope, out); |
|
| 75 | + | } |
|
| 76 | + | if pushed { |
|
| 77 | + | scope.pop(); |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | #[cfg(test)] |
|
| 82 | + | mod tests { |
|
| 83 | + | use super::*; |
|
| 84 | + | ||
| 85 | + | fn names(path: &str, text: &str) -> Vec<(String, usize, usize)> { |
|
| 86 | + | functions(path, text).into_iter().map(|f| (f.name, f.start, f.end)).collect() |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | #[test] |
|
| 90 | + | fn rust_functions_and_methods() { |
|
| 91 | + | let src = "use x;\n\nfn free() {\n a();\n}\n\nimpl Repo {\n pub fn log(&self) -> u8 {\n 1\n }\n}\n\ntrait T {\n fn t(&self);\n}\n"; |
|
| 92 | + | assert_eq!(names("src/lib.rs", src), [("free".to_string(), 3, 5), ("Repo::log".to_string(), 8, 10)]); |
|
| 93 | + | let generic = "impl<T> Wrap<T> {\n fn get(&self) {}\n}\n"; |
|
| 94 | + | assert_eq!(names("a.rs", generic), [("Wrap<T>::get".to_string(), 2, 2)]); |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | #[test] |
|
| 98 | + | fn typescript_python_and_shell() { |
|
| 99 | + | let ts = "export function activate(ctx: X): void {\n go();\n}\nclass Repo {\n refresh(): void {\n x();\n }\n}\n"; |
|
| 100 | + | assert_eq!(names("src/extension.ts", ts), [("activate".to_string(), 1, 3), ("Repo::refresh".to_string(), 5, 7)]); |
|
| 101 | + | let py = "def top():\n pass\n\nclass Recipe:\n def main(self):\n return 1\n"; |
|
| 102 | + | assert_eq!(names("images/rust/recipe.py", py), [("top".to_string(), 1, 2), ("Recipe::main".to_string(), 5, 6)]); |
|
| 103 | + | let sh = "#!/bin/sh\nbuild() {\n cargo build\n}\n"; |
|
| 104 | + | assert_eq!(names("build.sh", sh), [("build".to_string(), 2, 4)]); |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | #[test] |
|
| 108 | + | fn other_files_have_none() { |
|
| 109 | + | assert!(functions("README.md", "# fn main() {}\n").is_empty()); |
|
| 110 | + | assert!(functions("Makefile", "all:\n").is_empty()); |
|
| 111 | + | } |
|
| 112 | + | } |
added templates/function.html +25 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}History of {% if name.is_empty() %}lines {{ start }}–{{ end }}{% else %}{{ name }}{% endif %} · {{ path }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>History of <code>{% if name.is_empty() %}lines {{ start }}–{{ end }}{% else %}{{ name }}{% endif %}</code> <small>{{ entries.len() }} change{% if entries.len() != 1 %}s{% endif %}</small></h1> |
|
| 8 | + | <p class="small mute">In <a href="{{ repo.url }}/file/{{ r }}/{{ path }}#L{{ start }}">{{ path }}</a>, lines {{ start }}–{{ end }} on {{ r }}. Every change that wrote or deleted one of these lines, or deleted a line from between them. Exact, because each line keeps its identity across edits; edits elsewhere in the file are not here. Also: <code>oxy log -L {{ start }},{{ end }}:{{ path }}</code>.</p></div> |
|
| 9 | + | {% if entries.is_empty() %}<p class="empty">No changes.</p>{% else %} |
|
| 10 | + | <div class="hist" style="--lanes: {{ lanes }}"> |
|
| 11 | + | {% for c in entries %} |
|
| 12 | + | {% let row = rows[loop.index0] %} |
|
| 13 | + | <div class="hrow"> |
|
| 14 | + | <svg class="lanes" width="{{ lanes * 18 + 4 }}" height="100%" viewBox="0 0 {{ lanes * 18 + 4 }} 60" preserveAspectRatio="none" aria-hidden="true"> |
|
| 15 | + | {% for l in row.through %}<line x1="{{ l * 18 + 10 }}" y1="0" x2="{{ l * 18 + 10 }}" y2="60" class="lane"/>{% endfor %} |
|
| 16 | + | {% for e in row.edges %}<path d="M{{ e.0 * 18 + 10 }} 30 L{{ e.0 * 18 + 10 }} 44 L{{ e.1 * 18 + 10 }} 60" class="lane"/>{% endfor %} |
|
| 17 | + | <line x1="{{ row.lane * 18 + 10 }}" y1="0" x2="{{ row.lane * 18 + 10 }}" y2="30" class="lane{% if loop.first %} none{% endif %}"/> |
|
| 18 | + | <circle cx="{{ row.lane * 18 + 10 }}" cy="30" r="4" class="node{% if c.resolution %} res{% endif %}"/> |
|
| 19 | + | </svg> |
|
| 20 | + | {% call m::change_row(repo, c) %} |
|
| 21 | + | </div> |
|
| 22 | + | {% endfor %} |
|
| 23 | + | </div> |
|
| 24 | + | {% endif %} |
|
| 25 | + | {% endblock %} |
modified .flow/pipeline.kdl +2 −2
| … | 10 unchanged lines |
||
| 11 | 11 | // Cargo.lock changes; until then only the changed crates are compiled. |
|
| 12 | 12 | sandbox "rust" { |
|
| 13 | 13 | runtime "microvm" |
|
| 14 | - | rootfs "cas://blob/169de040f406eec3ec4af81400cff8736d0966c7532e73a44cdcf7d35cda05a4" |
|
| 14 | + | rootfs "cas://blob/9d76e1aef3ab096c57dfdab6ed2cfb4de1dc70e67ca0e7f299e087f7abb7e61a" |
|
| 15 | 15 | memory-limit "8GiB" |
|
| 16 | 16 | cpu-limit 4 |
|
| 17 | 17 | // Tests serve and connect on 127.0.0.1; nothing leaves the guest. |
|
| … | 2 unchanged lines |
||
| 20 | 20 | ||
| 21 | 21 | sandbox "rust-small" { |
|
| 22 | 22 | runtime "microvm" |
|
| 23 | - | rootfs "cas://blob/169de040f406eec3ec4af81400cff8736d0966c7532e73a44cdcf7d35cda05a4" |
|
| 23 | + | rootfs "cas://blob/9d76e1aef3ab096c57dfdab6ed2cfb4de1dc70e67ca0e7f299e087f7abb7e61a" |
|
| 24 | 24 | memory-limit "1GiB" |
|
| 25 | 25 | cpu-limit 1 |
|
| 26 | 26 | network "isolated" |
|
| … | 56 unchanged lines |
modified Cargo.lock +71 −0
| … | 2223 unchanged lines |
||
| 2224 | 2224 | "tower-http", |
|
| 2225 | 2225 | "tracing", |
|
| 2226 | 2226 | "tracing-subscriber", |
|
| 2227 | + | "tree-sitter", |
|
| 2228 | + | "tree-sitter-bash", |
|
| 2229 | + | "tree-sitter-python", |
|
| 2230 | + | "tree-sitter-rust", |
|
| 2231 | + | "tree-sitter-typescript", |
|
| 2227 | 2232 | "zip", |
|
| 2228 | 2233 | "zstd", |
|
| 2229 | 2234 | ] |
|
| … | 767 unchanged lines |
||
| 2997 | 3002 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2998 | 3003 | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" |
|
| 2999 | 3004 | dependencies = [ |
|
| 3005 | + | "indexmap", |
|
| 3000 | 3006 | "itoa", |
|
| 3001 | 3007 | "memchr", |
|
| 3002 | 3008 | "serde", |
|
| … | 396 unchanged lines |
||
| 3399 | 3405 | version = "1.2.1" |
|
| 3400 | 3406 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3401 | 3407 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" |
|
| 3408 | + | ||
| 3409 | + | [[package]] |
|
| 3410 | + | name = "streaming-iterator" |
|
| 3411 | + | version = "0.1.9" |
|
| 3412 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3413 | + | checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" |
|
| 3402 | 3414 | ||
| 3403 | 3415 | [[package]] |
|
| 3404 | 3416 | name = "string_cache" |
|
| … | 460 unchanged lines |
||
| 3865 | 3877 | "tracing", |
|
| 3866 | 3878 | "tracing-core", |
|
| 3867 | 3879 | "tracing-log", |
|
| 3880 | + | ] |
|
| 3881 | + | ||
| 3882 | + | [[package]] |
|
| 3883 | + | name = "tree-sitter" |
|
| 3884 | + | version = "0.27.0" |
|
| 3885 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3886 | + | checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" |
|
| 3887 | + | dependencies = [ |
|
| 3888 | + | "cc", |
|
| 3889 | + | "regex", |
|
| 3890 | + | "serde_json", |
|
| 3891 | + | "streaming-iterator", |
|
| 3892 | + | "tree-sitter-language", |
|
| 3893 | + | ] |
|
| 3894 | + | ||
| 3895 | + | [[package]] |
|
| 3896 | + | name = "tree-sitter-bash" |
|
| 3897 | + | version = "0.25.1" |
|
| 3898 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3899 | + | checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" |
|
| 3900 | + | dependencies = [ |
|
| 3901 | + | "cc", |
|
| 3902 | + | "tree-sitter-language", |
|
| 3903 | + | ] |
|
| 3904 | + | ||
| 3905 | + | [[package]] |
|
| 3906 | + | name = "tree-sitter-language" |
|
| 3907 | + | version = "0.1.8" |
|
| 3908 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3909 | + | checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" |
|
| 3910 | + | ||
| 3911 | + | [[package]] |
|
| 3912 | + | name = "tree-sitter-python" |
|
| 3913 | + | version = "0.25.0" |
|
| 3914 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3915 | + | checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" |
|
| 3916 | + | dependencies = [ |
|
| 3917 | + | "cc", |
|
| 3918 | + | "tree-sitter-language", |
|
| 3919 | + | ] |
|
| 3920 | + | ||
| 3921 | + | [[package]] |
|
| 3922 | + | name = "tree-sitter-rust" |
|
| 3923 | + | version = "0.24.2" |
|
| 3924 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3925 | + | checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" |
|
| 3926 | + | dependencies = [ |
|
| 3927 | + | "cc", |
|
| 3928 | + | "tree-sitter-language", |
|
| 3929 | + | ] |
|
| 3930 | + | ||
| 3931 | + | [[package]] |
|
| 3932 | + | name = "tree-sitter-typescript" |
|
| 3933 | + | version = "0.23.2" |
|
| 3934 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3935 | + | checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" |
|
| 3936 | + | dependencies = [ |
|
| 3937 | + | "cc", |
|
| 3938 | + | "tree-sitter-language", |
|
| 3868 | 3939 | ] |
|
| 3869 | 3940 | ||
| 3870 | 3941 | [[package]] |
|
| … | 672 unchanged lines |
modified crates/hub/Cargo.toml +7 −0
| … | 11 unchanged lines |
||
| 12 | 12 | path = "src/main.rs" |
|
| 13 | 13 | ||
| 14 | 14 | [dependencies] |
|
| 15 | + | # Function boundaries on file pages (hub spec §6.6): the grammars of the |
|
| 16 | + | # languages the Oxygen projects are written in. |
|
| 17 | + | tree-sitter = "0.27" |
|
| 18 | + | tree-sitter-rust = "0.24" |
|
| 19 | + | tree-sitter-typescript = "0.23" |
|
| 20 | + | tree-sitter-python = "0.25" |
|
| 21 | + | tree-sitter-bash = "0.25" |
|
| 15 | 22 | oxy-core.workspace = true |
|
| 16 | 23 | oxy-server.workspace = true |
|
| 17 | 24 | hub-api.workspace = true |
|
| … | 46 unchanged lines |
modified crates/hub/src/repos/mod.rs +1 −0
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod archive; |
|
| 9 | 9 | pub mod cache; |
|
| 10 | 10 | pub mod edits; |
|
| 11 | + | pub mod functions; |
|
| 11 | 12 | pub mod names; |
|
| 12 | 13 | pub mod proposals; |
|
| 13 | 14 | pub mod search; |
|
| … | 213 unchanged lines |
modified crates/hub/src/web/history.rs +69 −0
| … | 56 unchanged lines |
||
| 57 | 57 | Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes })) |
|
| 58 | 58 | } |
|
| 59 | 59 | ||
| 60 | + | #[derive(Deserialize)] |
|
| 61 | + | pub struct FunctionQuery { |
|
| 62 | + | start: usize, |
|
| 63 | + | end: usize, |
|
| 64 | + | #[serde(default)] |
|
| 65 | + | name: String, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | #[derive(Template)] |
|
| 69 | + | #[template(path = "function.html")] |
|
| 70 | + | pub struct FunctionPage { |
|
| 71 | + | shell: Shell, |
|
| 72 | + | repo: RepoShell, |
|
| 73 | + | r: String, |
|
| 74 | + | path: String, |
|
| 75 | + | name: String, |
|
| 76 | + | start: usize, |
|
| 77 | + | end: usize, |
|
| 78 | + | entries: Vec<view::ChangeView>, |
|
| 79 | + | rows: Vec<graph::Row>, |
|
| 80 | + | lanes: usize, |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | /// `GET /{owner}/{name}/function/{ref}/{path}?start=…&end=…&name=…`: the |
|
| 84 | + | /// history of a function (hub spec §6.6): every change that wrote or |
|
| 85 | + | /// deleted one of its lines, exactly (Oxygen spec §27), with a graph of how |
|
| 86 | + | /// those changes depend on each other. |
|
| 87 | + | pub async fn function( |
|
| 88 | + | State(app): State<Shared>, |
|
| 89 | + | viewer: Viewer, |
|
| 90 | + | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
|
| 91 | + | Query(q): Query<FunctionQuery>, |
|
| 92 | + | ) -> Result<Page<FunctionPage>, Error> { |
|
| 93 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/function/{r}/{path}"), &viewer); |
|
| 94 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 95 | + | let (spec, p, start, end) = (r.clone(), path.clone(), q.start, q.end); |
|
| 96 | + | let mut entries = app |
|
| 97 | + | .repos |
|
| 98 | + | .read(&repo, move |repo, cache| { |
|
| 99 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 100 | + | let history = repo.line_history(&target.heads, &p, start, end)?; |
|
| 101 | + | let labels = view::head_labels(repo)?; |
|
| 102 | + | Ok(history.iter().map(|e| view::change_view_pub(repo, &e.hash, &e.change, e.is_head, &labels)).collect::<Vec<_>>()) |
|
| 103 | + | }) |
|
| 104 | + | .await |
|
| 105 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 106 | + | // The graph shows how these changes depend on one another. |
|
| 107 | + | let listed: std::collections::HashSet<String> = entries.iter().map(|c| c.hash.clone()).collect(); |
|
| 108 | + | for c in &mut entries { |
|
| 109 | + | c.deps.retain(|d| listed.contains(d)); |
|
| 110 | + | } |
|
| 111 | + | super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await; |
|
| 112 | + | let graph_input: Vec<(String, Vec<String>)> = entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); |
|
| 113 | + | let rows = graph::layout(&graph_input); |
|
| 114 | + | let lanes = graph::width(&rows); |
|
| 115 | + | Ok(Page(FunctionPage { |
|
| 116 | + | shell, |
|
| 117 | + | repo: RepoShell::new(&app, &row, "code").with_role(role), |
|
| 118 | + | r, |
|
| 119 | + | path, |
|
| 120 | + | name: q.name, |
|
| 121 | + | start, |
|
| 122 | + | end, |
|
| 123 | + | entries, |
|
| 124 | + | rows, |
|
| 125 | + | lanes, |
|
| 126 | + | })) |
|
| 127 | + | } |
|
| 128 | + | ||
| 60 | 129 | /// A file's diff, rendered. |
|
| 61 | 130 | pub struct FileRows { |
|
| 62 | 131 | pub path: String, |
|
| … | 199 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 93 unchanged lines |
||
| 94 | 94 | .route("/{owner}/{name}/tree/{r}", get(repo::tree_root)) |
|
| 95 | 95 | .route("/{owner}/{name}/tree/{r}/{*path}", get(repo::tree)) |
|
| 96 | 96 | .route("/{owner}/{name}/file/{r}/{*path}", get(repo::file)) |
|
| 97 | + | .route("/{owner}/{name}/function/{r}/{*path}", get(history::function)) |
|
| 97 | 98 | .route("/{owner}/{name}/raw/{r}/{*path}", get(repo::raw_file)) |
|
| 98 | 99 | .route("/{owner}/{name}/last/{r}", get(repo::last_changes)) |
|
| 99 | 100 | .route("/{owner}/{name}/changes/{r}", get(history::history)) |
|
| … | 486 unchanged lines |
modified crates/hub/src/web/repo.rs +8 −1
| … | 192 unchanged lines |
||
| 193 | 193 | blame: Option<Vec<BlameRow>>, |
|
| 194 | 194 | /// The `{ref}` of this state for good (hub spec §6.6). |
|
| 195 | 195 | permalink: Option<String>, |
|
| 196 | + | /// Functions found in the file, each linking to its history. |
|
| 197 | + | functions: Vec<crate::repos::functions::Function>, |
|
| 196 | 198 | } |
|
| 197 | 199 | ||
| 198 | 200 | /// A blame gutter entry: shown only on the first line of a run. |
|
| … | 97 unchanged lines |
||
| 296 | 298 | _ => (vec![], None, None), |
|
| 297 | 299 | }; |
|
| 298 | 300 | let permalink = f.target.permalink(); |
|
| 299 | - | Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame, permalink }).into_response()) |
|
| 301 | + | let functions = match &f.body { |
|
| 302 | + | FileBody::Text(text) if !is_image => crate::repos::functions::functions(&f.path, text), |
|
| 303 | + | _ => vec![], |
|
| 304 | + | }; |
|
| 305 | + | Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame, permalink, functions }) |
|
| 306 | + | .into_response()) |
|
| 300 | 307 | } |
|
| 301 | 308 | ||
| 302 | 309 | /// Whether `path` is a file in the state `r` names. |
|
| … | 180 unchanged lines |
modified crates/hub/tests/api.rs +43 −0
| … | 882 unchanged lines |
||
| 883 | 883 | let anon = call(app, "GET", &format!("/andy/demo/change/{hash}"), None, None, None, None).await; |
|
| 884 | 884 | assert!(anon.body.contains("key not linked") && !anon.body.contains("link your key")); |
|
| 885 | 885 | } |
|
| 886 | + | ||
| 887 | + | /// Function history (hub spec §6.6): the file page lists a file's |
|
| 888 | + | /// functions, and a function's page lists exactly the changes to its lines. |
|
| 889 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 890 | + | async fn a_function_has_its_own_history() { |
|
| 891 | + | let h = hub().await; |
|
| 892 | + | let app = &h.app; |
|
| 893 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 894 | + | .await |
|
| 895 | + | .cookie |
|
| 896 | + | .unwrap(); |
|
| 897 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 898 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 899 | + | let url = format!("{}/andy/demo", h.base); |
|
| 900 | + | let work = tempfile::tempdir().unwrap(); |
|
| 901 | + | let w = work.path().to_path_buf(); |
|
| 902 | + | tokio::task::spawn_blocking(move || { |
|
| 903 | + | let mut c = Repo::init(&w).unwrap(); |
|
| 904 | + | c.config.set("remote.origin.url", &url).unwrap(); |
|
| 905 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 906 | + | c.save_config().unwrap(); |
|
| 907 | + | let steps = [ |
|
| 908 | + | ("add lib", "use x;\n\nimpl Repo {\n fn log(&self) {\n one();\n }\n}\n"), |
|
| 909 | + | ("change the import", "use y;\n\nimpl Repo {\n fn log(&self) {\n one();\n }\n}\n"), |
|
| 910 | + | ("log twice", "use y;\n\nimpl Repo {\n fn log(&self) {\n one();\n one();\n }\n}\n"), |
|
| 911 | + | ]; |
|
| 912 | + | for (msg, text) in steps { |
|
| 913 | + | write(&w, "src/lib.rs", text.as_bytes()); |
|
| 914 | + | c.commit(msg, &[]).unwrap(); |
|
| 915 | + | } |
|
| 916 | + | assert!(c.push("origin", false).unwrap()[0].ok); |
|
| 917 | + | }) |
|
| 918 | + | .await |
|
| 919 | + | .unwrap(); |
|
| 920 | + | let file = call(app, "GET", "/andy/demo/file/main/src/lib.rs", None, None, None, None).await.body; |
|
| 921 | + | assert!(file.contains("Functions") && file.contains("/andy/demo/function/main/src/lib.rs?start=4&end=7&name=Repo%3A%3Alog"), "{file}"); |
|
| 922 | + | let r = call(app, "GET", "/andy/demo/function/main/src/lib.rs?start=4&end=7&name=Repo%3A%3Alog", None, None, None, None).await; |
|
| 923 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 924 | + | assert!(r.body.contains("History of <code>Repo::log</code>") && r.body.contains("2 changes"), "{}", r.body); |
|
| 925 | + | assert!(r.body.contains("log twice") && r.body.contains("add lib") && !r.body.contains("change the import")); |
|
| 926 | + | let bad = call(app, "GET", "/andy/demo/function/main/src/lib.rs?start=4&end=99", None, None, None, None).await; |
|
| 927 | + | assert_ne!(bad.status, StatusCode::OK); |
|
| 928 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +2 −0
| … | 283 unchanged lines |
||
| 284 | 284 | ||
| 285 | 285 | The blame gutter shades each run by the age of its change, from the file's oldest (lightest) to newest (strongest) line, in ten steps, with the age also written out so colour is not the only carrier. Each run links **blame before this change**: the same file blamed at the state of the change's dependencies (the state its author built on), so a line can be traced back through every edit. A change without dependencies has no such link; one with more than 32 has none either, to keep addresses bounded. |
|
| 286 | 286 | ||
| 287 | + | **Function history.** For Rust, TypeScript, Python and shell files, tree-sitter finds the functions and methods (qualified by their impl, trait or class, as `Repo::log`) and the file page's sidebar lists them. Each links to `/{owner}/{name}/function/{ref}/{path}?start=…&end=…&name=…`: every change that wrote or deleted one of the function's lines, or deleted a line from between them (Oxygen spec §27, the query `oxy log -L` runs), newest first, with a graph of how those changes depend on one another. It is exact because lines keep their identity across edits; changes elsewhere in the file are not listed. A cross-file semantic graph (callers, callees) is future work. |
|
| 288 | + | ||
| 287 | 289 | ### 6.7 History |
|
| 288 | 290 | ||
| 289 | 291 | A dependency graph, not a parent chain. The default view lists the bookmark's changes newest first with lanes drawn between a change and its dependencies (the same layout algorithm as the VS Code extension's webview, ported to Rust and rendered server side as SVG). Heads are marked. Filters: author, path, date, signed/unsigned, message text. Each row: short hash, message first line, author (name, or KeyId when unlinked), relative time, signature glyph, bookmark and tag chips, a conflict chip when the change introduces one. |
|
| … | 295 unchanged lines |
modified templates/file.html +8 −0
| … | 20 unchanged lines |
||
| 21 | 21 | </li> |
|
| 22 | 22 | {% endfor %} |
|
| 23 | 23 | </ul> |
|
| 24 | + | {% if !functions.is_empty() %} |
|
| 25 | + | <p class="side-root" id="functions">Functions</p> |
|
| 26 | + | <ul class="functions"> |
|
| 27 | + | {% for fnc in functions %} |
|
| 28 | + | <li><a href="{{ repo.url }}/function/{{ f.target.label }}/{{ f.path }}?start={{ fnc.start }}&end={{ fnc.end }}&name={{ fnc.name|urlencode }}" title="History of {{ fnc.name }}, lines {{ fnc.start }}–{{ fnc.end }}">{{ fnc.name }}</a> <a class="small mute" href="#L{{ fnc.start }}">L{{ fnc.start }}</a></li> |
|
| 29 | + | {% endfor %} |
|
| 30 | + | </ul> |
|
| 31 | + | {% endif %} |
|
| 24 | 32 | </nav> |
|
| 25 | 33 | <section class="filebox file-main"> |
|
| 26 | 34 | <header> |
|
| … | 40 unchanged lines |