OXYGENHUB

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