1use std::path::PathBuf;
14
15use super::document::Surface;
16use super::{Editor, Mode};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub enum PermalinkIntent {
21 Yank,
23 Open,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct PendingPermalink {
34 pub intent: PermalinkIntent,
35 pub host: String,
37 pub repo: strop_git::RepoTarget,
39 pub remotes: Vec<(String, String)>,
42 pub sha: String,
44 #[serde(with = "strop_core::path_serde")]
46 pub path: PathBuf,
47 pub lines: (usize, usize),
49}
50
51#[derive(Debug)]
54pub(crate) enum PermalinkOutcome {
55 Url(String),
56 Alias(PendingPermalink),
57}
58
59impl Editor {
60 pub(crate) fn yank_permalink(&mut self) {
63 self.permalink_action(PermalinkIntent::Yank);
64 }
65
66 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 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 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 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 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 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 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 _ => Err("buffer does not belong to this repository".into()),
245 }
246 }
247}