Skip to main content

strop_engine/editor/
permalink.rs

1//! Permalinks (0011 §5, R6, 0033 finding 1): revision-pinned source
2//! locations → GitHub/GitLab URLs, yanked via OSC52 or opened with the
3//! platform opener. Building is pure — remotes and HEAD come from the
4//! cached git context, never a native read on the input path.
5//!
6//! An SSH-alias remote is the one asynchronous input: OpenSSH's
7//! effective configuration (`ssh -G` — Include, wildcard `Host` and
8//! `HostName` rules) is evaluated on the IO native worker, and the
9//! yank/open completes from the returned hostname. Until OpenSSH
10//! answers, nothing is copied and no browser is launched; a failed or
11//! unresolved alias is a visible diagnosis, never a guessed URL.
12
13use std::path::PathBuf;
14
15use super::document::Surface;
16use super::{Editor, Mode};
17
18/// What the finished URL is for.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub enum PermalinkIntent {
21    /// `space g y`: register + OSC52 payload.
22    Yank,
23    /// `space g o`: the platform opener.
24    Open,
25}
26
27/// Frozen pure data for a permalink whose SSH host alias is still
28/// unresolved: the alias's answer plus these fields rebuild the URL
29/// with no repository handle, no IO, and no state that moved
30/// meanwhile. The captured repository determines which machine and
31/// working directory own the OpenSSH configuration (0036 RW8).
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct PendingPermalink {
34    pub intent: PermalinkIntent,
35    /// The alias exactly as the winning remote spells it.
36    pub host: String,
37    /// Repository identity owns both the endpoint and remote working directory.
38    pub repo: strop_git::RepoTarget,
39    /// The (name, url) pairs the selection folded over — completion
40    /// re-runs the same pure fold against this frozen copy.
41    pub remotes: Vec<(String, String)>,
42    /// The pinned commit SHA, already resolved (0014): never a branch.
43    pub sha: String,
44    /// Repo-relative file path, native bytes.
45    #[serde(with = "strop_core::path_serde")]
46    pub path: PathBuf,
47    /// 1-based line range.
48    pub lines: (usize, usize),
49}
50
51/// Pure permalink construction: either a ready URL, or an SSH alias
52/// the IO worker must ask OpenSSH about.
53#[derive(Debug)]
54pub(crate) enum PermalinkOutcome {
55    Url(String),
56    Alias(PendingPermalink),
57}
58
59impl Editor {
60    /// `Space g y`: permalink for the cursor line (or visual range) —
61    /// SHA-resolved, remote-prioritized (0001 pillar 3.3).
62    pub(crate) fn yank_permalink(&mut self) {
63        self.permalink_action(PermalinkIntent::Yank);
64    }
65
66    /// `Space g o`: open the permalink in the browser. The opener
67    /// process is fire-and-forget — spawn only, never a wait.
68    pub(crate) fn open_permalink(&mut self) {
69        self.permalink_action(PermalinkIntent::Open);
70    }
71
72    fn permalink_action(&mut self, intent: PermalinkIntent) {
73        match self.build_permalink(intent) {
74            Ok(PermalinkOutcome::Url(url)) => self.finish_permalink(intent, url),
75            Ok(PermalinkOutcome::Alias(pending)) => self.request_ssh_host(pending),
76            Err(e) => self.message = e,
77        }
78    }
79
80    /// Publish a finished URL — the resolution is over, so the
81    /// clipboard/register (yank) or opener request (open) may act.
82    fn finish_permalink(&mut self, intent: PermalinkIntent, url: String) {
83        match intent {
84            PermalinkIntent::Yank => {
85                self.set_register(None, super::Register::characterwise(url.clone()));
86                self.osc52 = Some(url);
87                self.message = "permalink copied".into();
88            }
89            PermalinkIntent::Open => self.request_browser(url),
90        }
91    }
92
93    /// IO-worker completion: OpenSSH said what the alias means, so the
94    /// rebuild is pure from the frozen pending data.
95    pub(crate) fn complete_ssh_permalink(&mut self, pending: PendingPermalink, hostname: String) {
96        let Some(strop_git::permalink::SelectedRemote::Alias(alias)) =
97            strop_git::permalink::pick_remote(&pending.remotes)
98        else {
99            self.message = format!("permalink remote vanished: {}", pending.host);
100            return;
101        };
102        let Some(remote) = alias.resolved(&hostname) else {
103            self.message = format!(
104                "SSH host {} has no resolved web hostname; check ssh -G",
105                pending.host
106            );
107            return;
108        };
109        let url =
110            strop_git::permalink::permalink(&remote, &pending.sha, &pending.path, pending.lines);
111        self.finish_permalink(pending.intent, url);
112    }
113
114    /// Pure: every input (repository target, remotes, HEAD sha) is
115    /// cached git context — no libgit2 handle, no IO, no spawn (R6).
116    /// An alias remote comes back unresolved: evaluation is owned
117    /// worker work — on the repository's own endpoint when that
118    /// repository is remote (0036 RW8), locally otherwise.
119    pub(crate) fn build_permalink(
120        &self,
121        intent: PermalinkIntent,
122    ) -> Result<PermalinkOutcome, String> {
123        let Some(context) = self.git_context() else {
124            return Err("not a git repository".into());
125        };
126        // the repo-relative file: a commit delta carries it directly;
127        // a hunk preview names its origin buffer; otherwise the
128        // current buffer. Every branch resolves through the context's
129        // typed target, so a remote file can only produce a path
130        // relative to the REMOTE workdir.
131        let rel = match self.surface() {
132            Some(Surface::Diff {
133                commit: Some(commit),
134                ..
135            }) => commit.current.clone(),
136            Some(Surface::Diff {
137                origin: Some(origin),
138                ..
139            }) => {
140                let document = self
141                    .docs
142                    .get(origin.buffer)
143                    .ok_or("no source file for this diff")?;
144                self.permalink_rel_of(document, context)?
145            }
146            _ => self.permalink_rel_of(self.cur(), context)?,
147        };
148        if self.remote_file().is_some() && !self.remote_window_complete() {
149            return Err("partial remote windows have no full-file line coordinates".into());
150        }
151        let head_row = self.buf().line_of(self.head());
152        let anchor_row = if matches!(
153            self.mode,
154            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
155        ) {
156            self.buf().line_of(self.anchor())
157        } else {
158            head_row
159        };
160        let first = anchor_row.min(head_row);
161        let last = anchor_row.max(head_row);
162        let (a, b) = if let Some(surface @ Surface::Diff { .. }) = self.surface() {
163            let source_line = |row| match surface.diff_row(row) {
164                Some(super::DiffRow::Line(line)) => line
165                    .new_lineno
166                    .ok_or("deleted lines do not exist in this revision"),
167                _ => Err("select source content, not a diff header"),
168            };
169            for row in first..=last {
170                source_line(row)?;
171            }
172            (source_line(first)?, source_line(last)?)
173        } else {
174            (first + 1, last + 1)
175        };
176        if context.remotes.is_empty() {
177            return Err("no remote configured".into());
178        }
179        let selected = strop_git::permalink::pick_remote(&context.remotes)
180            .ok_or("no supported web remote configured")?;
181        // the location is revision-pinned (0014): on a commit surface it
182        // links THAT commit's file, on a working buffer it links HEAD;
183        // index/worktree content is local state, HEAD pins it
184        let sha = match self.surface() {
185            Some(Surface::Diff {
186                commit: Some(cf), ..
187            }) => cf.sha.clone(),
188            _ => context.head_sha.clone().ok_or("no HEAD commit")?,
189        };
190        Ok(match selected {
191            strop_git::permalink::SelectedRemote::Web(web) => PermalinkOutcome::Url(
192                strop_git::permalink::permalink(&web, &sha, &rel, (a.min(b), a.max(b))),
193            ),
194            strop_git::permalink::SelectedRemote::Alias(alias) => {
195                PermalinkOutcome::Alias(PendingPermalink {
196                    intent,
197                    host: alias.alias,
198                    repo: context.repo.clone(),
199                    remotes: context.remotes.clone(),
200                    sha,
201                    path: rel,
202                    lines: (a.min(b), a.max(b)),
203                })
204            }
205        })
206    }
207
208    /// A document's repo-relative path for permalink construction:
209    /// local buffers resolve under a local workdir, remote buffers
210    /// under their own endpoint's remote workdir — never across
211    /// machines.
212    fn permalink_rel_of(
213        &self,
214        document: &super::Document,
215        context: &strop_git::GitContext,
216    ) -> Result<PathBuf, String> {
217        match (&document.source, &context.repo) {
218            (
219                super::document::DocumentSource::Remote(file),
220                strop_git::RepoTarget::Remote { endpoint, .. },
221            ) if endpoint == file.file.endpoint() => context
222                .repo
223                .rel_of(file.file.path())
224                .ok_or_else(|| format!("{} is outside the repo", file.file.path().display())),
225            (super::document::DocumentSource::File, strop_git::RepoTarget::Local { .. }) => {
226                let path = document
227                    .buf
228                    .path
229                    .as_deref()
230                    .ok_or("no file for this buffer")?;
231                let abs = if path.is_absolute() {
232                    path.to_path_buf()
233                } else {
234                    context.workdir().join(path)
235                };
236                context
237                    .repo
238                    .rel_of(&abs)
239                    .ok_or_else(|| format!("{} is outside the repo", abs.display()))
240            }
241            // a remote buffer under a local repository (or the
242            // reverse) has no rel path HERE — typed refusal, never a
243            // cross-machine strip
244            _ => Err("buffer does not belong to this repository".into()),
245        }
246    }
247}