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