1mod context;
7
8use std::sync::Arc;
9use strop_core::worker::{CancelReason, Load, Outcome};
10use strop_git::Hunk;
11
12use crate::files::FileTarget;
13
14use super::git_memory::{
15 git_failure, repo_or_unavailable, GitJob, GitMutation, HunkData, HunkKey, HunkSet, MutationKey,
16 MutationKind, MutationOp,
17};
18use super::transact::ChangeSet;
19use super::Editor;
20
21enum HunkTarget {
23 NotASurface,
25 Fresh {
28 buffer: strop_core::id::DocumentId,
29 hunk: Arc<Hunk>,
30 untracked: bool,
32 },
33 Stale,
36}
37
38impl Editor {
39 pub fn refresh_hunks(&mut self) {
43 if self.docs.is_empty() || self.finishing {
44 return;
45 }
46 let Some(context) = self.git.clone() else {
47 return; };
49 let doc = self.current();
50 let Some(document) = self.docs.get(doc) else {
51 return;
52 };
53 let revision = document.buf.revision();
54 let file = match &document.source {
57 super::document::DocumentSource::Remote(remote) => {
58 FileTarget::Remote(remote.file.clone().into())
59 }
60 super::document::DocumentSource::File => match document.buf.path.as_deref() {
61 Some(path) => FileTarget::Local(std::path::PathBuf::from(path)),
62 None => {
64 self.clear_hunk_view();
65 return;
66 }
67 },
68 _ => {
70 self.clear_hunk_view();
71 return;
72 }
73 };
74 let remote_file = match (&file, &context.repo) {
79 (FileTarget::Remote(remote), strop_git::RepoTarget::Remote { endpoint, .. })
80 if endpoint == remote.endpoint() =>
81 {
82 remote.absolute_file()
83 }
84 (FileTarget::Local(_), strop_git::RepoTarget::Local { .. }) => None,
85 _ => {
86 self.clear_hunk_view();
87 return;
88 }
89 };
90 if remote_file.is_some() && !self.remote_window_complete() {
91 self.clear_hunk_view();
94 return;
95 }
96 let key = HunkKey {
97 document: doc,
98 revision,
99 file: file.clone(),
100 repo: context.repo.clone(),
101 git_view: self.git_view,
102 };
103 if self.hunk_load.covers(&key) {
104 return; }
106 let snapshot = document.buf.snapshot();
107 self.cancel_hunk_owner();
110 let Some(ticket) = self.git_ticket(key) else {
111 return;
112 };
113 self.hunk_load = Load::Running(ticket.clone());
114 self.hunks = HunkSet::default();
117 self.staged_hunks = HunkSet::default();
118 self.hunks_untracked = false;
119 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
120 serde_json::json!({
121 "service":"git","request":"hunks",
122 "target":if context.repo.is_remote() { "remote" } else { "local" },
123 "document":{"slot":doc.index(),"generation":doc.generation()},
124 "revision":revision.get(),"file":file.to_string(),
125 })
126 });
127 let args = ticket.clone();
128 if let Some(remote) = remote_file {
129 let endpoint = remote.endpoint().clone();
132 let workdir = context.repo.workdir().to_path_buf();
133 let head_sha = context.head_sha.clone();
134 let rel = context.repo.rel_of(remote.path());
135 self.launch_git_job(
136 "git-hunks-remote",
137 "git.hunks",
138 ticket,
139 &args,
140 GitJob::Hunks,
141 move |cancel| {
142 if cancel.is_cancelled() {
143 return Outcome::Cancelled(CancelReason::Superseded);
144 }
145 let Some(rel) = rel else {
146 return Outcome::failed(
147 strop_core::worker::FailureKind::Exit,
148 "buffer is outside the remote repository",
149 );
150 };
151 let text = snapshot.to_string();
152 match strop_git::remote::gutter(
153 &endpoint,
154 &workdir,
155 head_sha.as_deref(),
156 &rel,
157 &text,
158 &cancel,
159 ) {
160 Ok((unstaged, staged, untracked)) => Outcome::Success(HunkData {
161 unstaged: HunkSet::new(unstaged, snapshot.len_lines()),
162 staged: HunkSet::new(staged, snapshot.len_lines()),
163 untracked,
164 }),
165 Err(error) => Outcome::Failed {
166 failure: strop_core::worker::Failure::new(
167 strop_core::worker::FailureKind::Exit,
168 error.to_string(),
169 ),
170 partial: None,
171 },
172 }
173 },
174 );
175 return;
176 }
177 let workdir = context.repo.workdir().to_path_buf();
178 let path = match &file {
179 FileTarget::Local(path) => path.clone(),
180 FileTarget::Remote(_) => unreachable!("remote handled above"),
181 FileTarget::Container { .. } => {
185 self.clear_hunk_view();
186 return;
187 }
188 };
189 self.launch_git_job(
190 "git-hunks",
191 "git.hunks",
192 ticket,
193 &args,
194 GitJob::Hunks,
195 move |cancel| {
196 if cancel.is_cancelled() {
197 return Outcome::Cancelled(CancelReason::Superseded);
198 }
199 let text = snapshot.to_string();
200 let repo = match repo_or_unavailable(&workdir) {
201 Ok(repo) => repo,
202 Err(failure) => {
203 return Outcome::Failed {
204 failure,
205 partial: None,
206 }
207 }
208 };
209 let unstaged = match repo.unstaged_hunks(&path, &text) {
210 Ok(hunks) => hunks,
211 Err(error) => {
212 return Outcome::Failed {
213 failure: git_failure("diff index↔buffer", error),
214 partial: None,
215 }
216 }
217 };
218 let staged = match repo.staged_hunks(&path) {
219 Ok(hunks) => hunks,
220 Err(error) => {
221 return Outcome::Failed {
222 failure: git_failure("diff HEAD↔index", error),
223 partial: None,
224 }
225 }
226 };
227 let untracked = match repo.is_untracked(&path) {
228 Ok(untracked) => untracked,
229 Err(error) => {
230 return Outcome::Failed {
231 failure: git_failure("index lookup", error),
232 partial: None,
233 }
234 }
235 };
236 Outcome::Success(HunkData {
237 unstaged: HunkSet::new(unstaged, snapshot.len_lines()),
238 staged: HunkSet::new(staged, snapshot.len_lines()),
239 untracked,
240 })
241 },
242 );
243 }
244
245 fn clear_hunk_view(&mut self) {
250 self.cancel_hunk_owner();
251 self.hunks = HunkSet::default();
252 self.staged_hunks = HunkSet::default();
253 self.hunks_untracked = false;
254 }
255
256 fn cancel_hunk_owner(&mut self) {
260 let running = match &self.hunk_load {
261 Load::Running(ticket) => Some(ticket.request),
262 _ => None,
263 };
264 if let Some(request) = running {
265 self.cancel_git_worker(request, CancelReason::Superseded);
266 }
267 self.hunk_load = Load::Idle;
268 }
269
270 pub fn sign_at(&self, line_1based: usize) -> Option<char> {
273 self.hunks.sign(line_1based)
274 }
275
276 pub fn sign_at_staged(&self, line_1based: usize) -> bool {
281 self.staged_hunks.addition(line_1based)
282 }
283
284 pub(crate) fn jump_hunk(&mut self, forward: bool) {
287 if self.remote_file().is_some() && !self.remote_window_complete() {
290 self.message = "partial remote snapshot — hunks need a full window".into();
291 return;
292 }
293 self.hunk_load.retry_failed();
294 self.refresh_hunks();
295 let cur = self.buf().line_of(self.head()) + 1;
296 let target = self.hunks.next_line(cur, forward);
297 match target {
298 Some(l) => {
299 self.set_head(self.buf().line_start(l - 1));
300 self.clamp_cursor();
301 }
302 None => self.message = "no more hunks".into(),
303 }
304 }
305
306 fn hunk_under_cursor(&mut self) -> Option<Arc<Hunk>> {
308 self.hunk_load.retry_failed();
309 self.refresh_hunks();
310 let line = self.buf().line_of(self.head()) + 1;
311 self.hunks
312 .at_line(line)
313 .map(|index| self.hunks[index].clone())
314 }
315
316 fn restore_hunk_in(
321 &mut self,
322 idx: strop_core::id::DocumentId,
323 hunk: &Hunk,
324 untracked: bool,
325 ) -> bool {
326 if self.doc(idx).buf.path.is_none() || untracked {
327 return false; }
329 let new_first = hunk.changed_region().0;
330 let old: String = {
334 let mut bytes = Vec::new();
335 for l in hunk
336 .lines
337 .iter()
338 .filter(|l| l.origin != strop_git::LineOrigin::Addition)
339 {
340 bytes.extend_from_slice(&l.bytes_with_terminator());
341 }
342 match String::from_utf8(bytes) {
343 Ok(text) => text,
344 Err(error) => {
345 self.message = format!("hunk contains non-UTF-8 text: {error}");
346 return false;
347 }
348 }
349 };
350
351 let base = self.doc(idx).buf.revision();
354 let first = if hunk.new_count == 0 {
357 hunk.new_start
358 } else {
359 hunk.new_start.saturating_sub(1)
360 };
361 let start = self
362 .doc(idx)
363 .buf
364 .line_start(first)
365 .min(self.doc(idx).buf.len_bytes());
366 let end = if hunk.new_count == 0 {
367 start
368 } else {
369 self.doc(idx)
370 .buf
371 .line_start(first.saturating_add(hunk.new_count))
372 .min(self.doc(idx).buf.len_bytes())
373 };
374 let replacement =
375 strop_core::Replacement::new(strop_core::Range::charwise(start, end), old);
376 if let Err(error) = self.apply(
377 idx,
378 base,
379 ChangeSet {
380 edits: vec![replacement],
381 undo_open: false,
382 },
383 ) {
384 self.message = match error {
385 super::transact::ApplyError::Edit(strop_core::EditError::StaleRevision {
386 ..
387 }) => "buffer changed — reopen the hunk preview".into(),
388 other => format!("hunk reset failed: {other}"),
389 };
390 return false;
391 }
392 let land = self
395 .doc(idx)
396 .buf
397 .line_start((new_first - 1).min(self.doc(idx).buf.len_lines().saturating_sub(1)));
398 if self.current() == idx {
399 self.set_head(land);
400 self.clamp_cursor();
401 self.flash(strop_core::Range::charwise(self.head(), self.head()));
402 } else if let Some(pane) = self.panes.iter_mut().find(|p| p.doc == idx) {
403 pane.sels.collapse_primary(land);
404 }
405 true
406 }
407
408 pub(crate) fn undo_hunk(&mut self) {
413 if self.remote_endpoint().is_some() {
414 self.message = "remote Git mutations are not supported".into();
415 return;
416 }
417 let remote_refusal = |editor: &Self, idx| {
418 matches!(
419 editor.docs.get(idx).map(|d| &d.source),
420 Some(super::document::DocumentSource::Remote(_))
421 )
422 .then(|| "remote snapshots are read-only — hunk reset refused".to_string())
423 };
424 match self.hunk_surface_target() {
425 HunkTarget::Fresh {
426 buffer,
427 hunk,
428 untracked,
429 } => {
430 if let Some(message) = remote_refusal(self, buffer) {
431 self.message = message;
432 } else if self.restore_hunk_in(buffer, &hunk, untracked) {
433 self.message = "hunk reset".into();
434 }
435 }
436 HunkTarget::Stale => self.message = "buffer changed — reopen the hunk preview".into(),
437 HunkTarget::NotASurface => {
438 let Some(hunk) = self.hunk_under_cursor() else {
439 self.message = "no hunk here".into();
440 return;
441 };
442 if let Some(message) = remote_refusal(self, self.current()) {
443 self.message = message;
444 return;
445 }
446 let untracked = self.hunks_untracked;
447 if self.restore_hunk_in(self.current(), &hunk, untracked) {
448 self.message = "hunk reset".into();
449 }
450 }
451 }
452 }
453
454 pub(crate) fn stage_hunk(&mut self) {
460 if self.remote_endpoint().is_some() {
461 self.message = "remote Git mutations are not supported".into();
462 return;
463 }
464 match self.hunk_surface_target() {
465 HunkTarget::Fresh { buffer, hunk, .. } => self.stage_hunk_in(buffer, &hunk),
466 HunkTarget::Stale => self.message = "buffer changed — reopen the hunk preview".into(),
467 HunkTarget::NotASurface => {
468 let Some(hunk) = self.hunk_under_cursor() else {
469 self.message = "no hunk here".into();
470 return;
471 };
472 self.stage_hunk_in(self.current(), &hunk);
473 }
474 }
475 }
476
477 fn stage_hunk_in(&mut self, idx: strop_core::id::DocumentId, hunk: &Arc<Hunk>) {
478 let Some(path) = self.doc(idx).buf.path.clone() else {
479 return;
480 };
481 if self.doc(idx).buf.dirty {
485 self.message = "unsaved changes — :w first, then stage".into();
486 return;
487 }
488 let Some(context) = self.git.clone() else {
489 self.message = "not a git repo".into();
490 return;
491 };
492 if context.repo.is_remote() {
493 self.message = "remote repositories are read-only — staging is refused (RW4)".into();
494 return;
495 }
496 let Ok(rel) = std::path::Path::new(&path)
497 .strip_prefix(context.workdir())
498 .map(|p| p.to_path_buf())
499 else {
500 self.message = "buffer not under workdir".into();
501 return;
502 };
503 let key = MutationKey {
504 document: idx,
505 revision: self.doc(idx).buf.revision(),
506 kind: MutationKind::Stage,
507 rel,
508 repo: context.repo.clone(),
509 git_view: self.git_view,
510 };
511 self.git_mutations.push_back(GitMutation {
512 key,
513 op: MutationOp::Stage { hunk: hunk.clone() },
514 });
515 self.pump_git_mutations();
516 }
517
518 pub(crate) fn unstage_hunk(&mut self) {
523 if self.remote_endpoint().is_some() {
524 self.message = "remote Git mutations are not supported".into();
525 return;
526 }
527 if self.buf().dirty {
528 self.message = "unsaved changes — :w first".into();
529 return;
530 }
531 let line = self.buf().line_of(self.head()) + 1;
532 let Some(hunk) = self
533 .staged_hunks
534 .at_line(line)
535 .map(|index| self.staged_hunks[index].clone())
536 else {
537 self.message = "no staged hunk here".into();
538 return;
539 };
540 let Some(path) = self.buf().path.clone() else {
541 return;
542 };
543 let Some(context) = self.git.clone() else {
544 self.message = "not a git repo".into();
545 return;
546 };
547 if context.repo.is_remote() {
548 self.message = "remote repositories are read-only — unstaging is refused (RW4)".into();
549 return;
550 }
551 let Ok(rel) = std::path::Path::new(&path)
552 .strip_prefix(context.workdir())
553 .map(|p| p.to_path_buf())
554 else {
555 self.message = "buffer not under workdir".into();
556 return;
557 };
558 let key = MutationKey {
559 document: self.current(),
560 revision: self.buf().revision(),
561 kind: MutationKind::Unstage,
562 rel,
563 repo: context.repo.clone(),
564 git_view: self.git_view,
565 };
566 self.git_mutations.push_back(GitMutation {
567 key,
568 op: MutationOp::Unstage { hunk },
569 });
570 self.pump_git_mutations();
571 }
572
573 pub(crate) fn preview_hunk(&mut self) {
577 self.hunk_load.retry_failed();
578 self.refresh_hunks();
579 let line = self.buf().line_of(self.head()) + 1;
580 let Some(index) = self.hunks.at_line(line) else {
581 self.message = "no hunk here".into();
582 return;
583 };
584 let origin = super::git_memory::HunkOrigin {
585 buffer: self.current(),
586 revision: self.cur().buf.revision(),
587 untracked: self.hunks_untracked,
588 };
589 let Some(context) = self.git_context() else {
590 return;
591 };
592 let key = super::git_memory::DiveKey {
593 document: self.current(),
594 repo: context.repo.clone(),
595 target: super::git_memory::DiveTarget::HunkPreview { origin, index },
596 };
597 self.message = "loading hunk…".into();
598 self.register_dive(key);
599 }
600
601 fn hunk_surface_target(&self) -> HunkTarget {
605 let Some(super::Surface::Diff { hunks, origin, .. }) = self.surface() else {
606 return HunkTarget::NotASurface;
607 };
608 let Some(origin) = origin else {
609 return HunkTarget::NotASurface; };
611 let Some(hunk) = hunks.first() else {
612 return HunkTarget::NotASurface;
613 };
614 match self.docs.get(origin.buffer) {
615 Some(d) if d.buf.revision() == origin.revision => HunkTarget::Fresh {
616 buffer: origin.buffer,
617 hunk: hunk.clone(),
618 untracked: origin.untracked,
619 },
620 _ => HunkTarget::Stale,
621 }
622 }
623}
624
625#[cfg(test)]
626mod tests;