Skip to main content

yghfl/
main.rs

1//! Copy GitHub URL (file/line/col) for the current Helix buffer to clipboard.
2//!
3//! # Errors
4//! - `WezTerm`, status line parsing, or Git operations fail.
5
6use core::str::FromStr;
7use std::path::Component;
8use std::path::Path;
9use std::path::PathBuf;
10use std::process::Command;
11use std::sync::Arc;
12
13use rootcause::prelude::ResultExt as _;
14use rootcause::report;
15use url::Url;
16use ytil_editor::Editor;
17use ytil_hx::HxCursorPosition;
18use ytil_hx::HxStatusLine;
19use ytil_sys::cli::Args;
20use ytil_wezterm::WeztermPane;
21use ytil_wezterm::get_sibling_pane_with_titles;
22
23/// Builds absolute file path for Helix cursor position.
24///
25/// # Errors
26/// - Expanding a home-relative path (starting with `~`) fails because the home directory cannot be determined.
27fn build_hx_cursor_absolute_file_path(hx_cursor_file_path: &Path, hx_pane: &WeztermPane) -> rootcause::Result<PathBuf> {
28    if let Ok(hx_cursor_file_path) = hx_cursor_file_path.strip_prefix("~") {
29        return ytil_sys::dir::build_home_path(&[hx_cursor_file_path]);
30    }
31
32    let mut components = hx_pane.cwd.components();
33    components.next();
34    components.next();
35
36    Ok(std::iter::once(Component::RootDir)
37        .chain(components)
38        .chain(hx_cursor_file_path.components())
39        .collect())
40}
41
42/// Builds GitHub link pointing to specific file and line.
43///
44/// # Errors
45/// - UTF-8 conversion fails.
46fn build_github_link<'a>(
47    github_repo_url: &'a Url,
48    git_current_branch: &'a str,
49    file_path: &'a Path,
50    hx_cursor_position: &'a HxCursorPosition,
51) -> rootcause::Result<Url> {
52    let mut file_path_parts = vec![];
53    for component in file_path.components() {
54        file_path_parts.push(
55            component
56                .as_os_str()
57                .to_str()
58                .ok_or_else(|| report!("path component invalid utf-8"))
59                .attach_with(|| format!("component={component:#?}"))?,
60        );
61    }
62
63    let segments = [&["tree", git_current_branch], file_path_parts.as_slice()].concat();
64    let mut github_link = github_repo_url.clone();
65    github_link
66        .path_segments_mut()
67        .map_err(|()| {
68            report!("cannot extend url with segments").attach(format!("url={github_repo_url} segments={segments:#?}"))
69        })?
70        .extend(&segments);
71    github_link.set_fragment(Some(&format!(
72        "L{}C{}",
73        hx_cursor_position.line, hx_cursor_position.column
74    )));
75
76    Ok(github_link)
77}
78
79/// Copy GitHub URL (file/line/col) for the current Helix buffer to clipboard.
80#[ytil_sys::main]
81fn main() -> rootcause::Result<()> {
82    let args = ytil_sys::cli::get();
83    if args.has_help() {
84        println!("{}", include_str!("../help.txt"));
85        return Ok(());
86    }
87
88    let hx_pane = get_sibling_pane_with_titles(
89        &ytil_wezterm::get_all_panes(&[])?,
90        ytil_wezterm::get_current_pane_id()?,
91        Editor::Hx.pane_titles(),
92    )?;
93
94    let wezterm_pane_text = String::from_utf8(
95        Command::new("wezterm")
96            .args(["cli", "get-text", "--pane-id", &hx_pane.pane_id.to_string()])
97            .output()?
98            .stdout,
99    )?;
100
101    let hx_status_line = HxStatusLine::from_str(
102        wezterm_pane_text
103            .lines()
104            .nth_back(1)
105            .ok_or_else(|| report!("missing hx status line"))
106            .attach_with(|| format!("pane_id={} text={wezterm_pane_text:#?}", hx_pane.pane_id))?,
107    )?;
108
109    let git_repo_root_path = Arc::new(ytil_git::repo::get_root(&ytil_git::repo::discover(
110        &hx_status_line.file_path,
111    )?));
112
113    let get_git_current_branch =
114        std::thread::spawn(move || -> rootcause::Result<String> { ytil_git::branch::get_current() });
115
116    let git_repo_root_path_clone = git_repo_root_path.clone();
117    let get_github_repo_url = std::thread::spawn(move || -> rootcause::Result<Url> {
118        match &ytil_gh::get_repo_urls(&git_repo_root_path_clone)?.as_slice() {
119            &[] => Err(report!("missing GitHub repo URL").attach(format!("repo_path={git_repo_root_path_clone:#?}")))?,
120            &[one] => Ok(one.clone()),
121            multi => Err(report!("multiple GitHub repo URLs")
122                .attach(format!("URLs={multi:#?} repo_path={git_repo_root_path_clone:#?}")))?,
123        }
124    });
125
126    // `build_file_path_relative_to_git_repo_root` are called before the threads `join` to let them work in the
127    // background as much as possible
128    let hx_cursor_absolute_file_path = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane)?;
129
130    let github_link = build_github_link(
131        &ytil_sys::join(get_github_repo_url)?,
132        &ytil_sys::join(get_git_current_branch)?,
133        hx_cursor_absolute_file_path.strip_prefix(git_repo_root_path.as_ref())?,
134        &hx_status_line.position,
135    )?;
136
137    ytil_sys::file::cp_to_system_clipboard(&mut github_link.as_str().as_bytes())?;
138
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use fake::Fake;
145    use fake::Faker;
146
147    use super::*;
148
149    #[test]
150    fn test_build_hx_cursor_absolute_file_path_works_as_expected_with_file_path_as_relative_to_home_dir() {
151        // Arrange
152        temp_env::with_vars([("HOME", Some("/Users/Foo"))], || {
153            let hx_status_line = HxStatusLine {
154                file_path: Path::new("~/src/bar/baz.rs").into(),
155                ..Faker.fake()
156            };
157            let hx_pane = WeztermPane {
158                cwd: Path::new("file://hostname/Users/Foo/dev").into(),
159                ..Faker.fake()
160            };
161
162            // Act
163            let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane);
164
165            // Assert
166            let expected = Path::new("/Users/Foo/src/bar/baz.rs").to_path_buf();
167            assert_eq!(result.unwrap(), expected);
168        });
169    }
170
171    #[test]
172    fn test_build_hx_cursor_absolute_file_path_works_as_expected_with_file_path_as_relative_to_hx_root() {
173        // Arrange
174        let hx_status_line = HxStatusLine {
175            file_path: Path::new("src/bar/baz.rs").into(),
176            ..Faker.fake()
177        };
178        let hx_pane = WeztermPane {
179            cwd: Path::new("file://hostname/Users/Foo/dev").into(),
180            ..Faker.fake()
181        };
182
183        // Act
184        let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane).unwrap();
185
186        // Assert
187        let expected = Path::new("/Users/Foo/dev/src/bar/baz.rs").to_path_buf();
188        assert_eq!(expected, result);
189    }
190
191    #[test]
192    fn test_build_hx_cursor_absolute_file_path_works_as_expected_with_file_path_as_absolute() {
193        // Arrange
194        let hx_status_line = HxStatusLine {
195            file_path: Path::new("/Users/Foo/dev/src/bar/baz.rs").into(),
196            ..Faker.fake()
197        };
198        let hx_pane = WeztermPane {
199            cwd: Path::new("file://hostname/Users/Foo/dev").into(),
200            ..Faker.fake()
201        };
202
203        // Act
204        let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane).unwrap();
205
206        // Assert
207        let expected = Path::new("/Users/Foo/dev/src/bar/baz.rs").to_path_buf();
208        assert_eq!(expected, result);
209    }
210}