Skip to content

Commit 2491e39

Browse files
committed
fix(core): harden repo and diff behavior
1 parent 1fbd3cd commit 2491e39

57 files changed

Lines changed: 3634 additions & 1495 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ serde_json = "1"
2727
sha2 = "0.11.0"
2828
hex = "0.4"
2929
similar = "3.1.1"
30+
unicode-width = "0.2"
31+
unicode-segmentation = "1"
32+
nucleo-matcher = "0.3"
3033
toml = "1.1"
3134
directories = "6"
3235

crates/jayjay-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ gix-url = { workspace = true }
1414
percent-encoding = { workspace = true }
1515
jj-diff = { version = "0.1.0", path = "../jj-diff" }
1616
jayjay-network = { version = "0.1.0", path = "../jayjay-network" }
17+
nucleo-matcher = { workspace = true }
1718
thiserror = { workspace = true }
1819
pollster = { workspace = true }
1920
chrono = { workspace = true }

crates/jayjay-core/src/fuzzy.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
2+
use nucleo_matcher::{Config, Matcher, Utf32Str};
3+
4+
/// Rank `candidates` against `query`, best match first; returns their indices.
5+
/// Whitespace splits the query into atoms that must all match. An empty query
6+
/// keeps every candidate in original order.
7+
pub fn rank(query: &str, candidates: &[String]) -> Vec<u32> {
8+
let query = query.trim();
9+
if query.is_empty() {
10+
return (0..candidates.len() as u32).collect();
11+
}
12+
let mut matcher = Matcher::new(Config::DEFAULT);
13+
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
14+
let mut buf = Vec::new();
15+
let mut scored: Vec<(u32, u32)> = candidates
16+
.iter()
17+
.enumerate()
18+
.filter_map(|(ix, candidate)| {
19+
pattern
20+
.score(Utf32Str::new(candidate, &mut buf), &mut matcher)
21+
.map(|score| (score, ix as u32))
22+
})
23+
.collect();
24+
// Ties keep declaration order.
25+
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
26+
scored.into_iter().map(|(_, ix)| ix).collect()
27+
}
28+
29+
#[cfg(test)]
30+
mod tests {
31+
use super::rank;
32+
33+
fn candidates(items: &[&str]) -> Vec<String> {
34+
items.iter().map(|s| s.to_string()).collect()
35+
}
36+
37+
#[test]
38+
fn empty_query_keeps_original_order() {
39+
let items = candidates(&["b", "a"]);
40+
assert_eq!(rank("", &items), vec![0, 1]);
41+
assert_eq!(rank(" ", &items), vec![0, 1]);
42+
}
43+
44+
#[test]
45+
fn matches_subsequences_case_insensitively() {
46+
let items = candidates(&["Toggle Side-by-side Diff", "Open Settings"]);
47+
assert_eq!(rank("tsd", &items), vec![0]);
48+
assert_eq!(rank("SETT", &items), vec![1]);
49+
assert!(rank("bookmark", &items).is_empty());
50+
}
51+
52+
#[test]
53+
fn all_query_words_must_match() {
54+
let items = candidates(&["Toggle Tree File List tree file folder list", "Refresh"]);
55+
assert_eq!(rank("tree list", &items), vec![0]);
56+
assert!(rank("tree bookmark", &items).is_empty());
57+
}
58+
59+
#[test]
60+
fn closer_matches_rank_first() {
61+
let items = candidates(&["The Memo Editor", "Theme: Dark", "Theme: Light"]);
62+
let ranked = rank("theme", &items);
63+
assert_eq!(ranked.len(), 3);
64+
// Contiguous "Theme" beats the scattered match; ties keep order.
65+
assert_eq!(&ranked[..2], &[1, 2]);
66+
}
67+
}

crates/jayjay-core/src/jj_command.rs

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
use std::path::Path;
2-
use std::process::Command;
32

4-
use crate::{CoreError, CoreResult, jj_binary};
3+
use crate::{CoreError, CoreResult, jj_binary, repo::subprocess_command};
4+
5+
/// Palette runs capture stdout/stderr, so an interactive editor would hang
6+
/// forever. `["false"]` makes editor-requiring commands (`describe`/`commit`/
7+
/// `split` without -m) fail fast with a clear "Failed to edit" error.
8+
const NON_INTERACTIVE_ARGS: &[&str] = &["--config", r#"ui.editor=["false"]"#];
59

610
#[derive(Debug, Clone, PartialEq, Eq)]
711
pub struct JjCommand {
@@ -54,7 +58,8 @@ impl JjCommand {
5458
});
5559
}
5660

57-
let output = Command::new(jj_binary())
61+
let output = subprocess_command(&jj_binary())
62+
.args(NON_INTERACTIVE_ARGS)
5863
.args(&args)
5964
.current_dir(path)
6065
.output()
@@ -204,14 +209,18 @@ mod tests {
204209

205210
#[test]
206211
fn extracts_prefixed_command_body() {
207-
assert_eq!(
208-
JjCommand::from_palette_query("jj log").map(JjCommand::into_raw),
209-
Some("log".to_owned())
210-
);
211-
assert_eq!(
212-
JjCommand::from_palette_query("!status").map(JjCommand::into_raw),
213-
Some("status".to_owned())
214-
);
215-
assert_eq!(JjCommand::from_palette_query("status"), None);
212+
let body = |q: &str| JjCommand::from_palette_query(q).map(JjCommand::into_raw);
213+
assert_eq!(body("jj log"), Some("log".to_owned()));
214+
assert_eq!(body("!status"), Some("status".to_owned()));
215+
// Bare prefixes mean "jj mode, no body yet".
216+
assert_eq!(body("jj"), Some(String::new()));
217+
assert_eq!(body("jj "), Some(String::new()));
218+
assert_eq!(body("!"), Some(String::new()));
219+
// Leading whitespace after the prefix is trimmed.
220+
assert_eq!(body("jj log"), Some("log".to_owned()));
221+
assert_eq!(body("! status"), Some("status".to_owned()));
222+
// Plain words are not jj commands.
223+
assert_eq!(body("status"), None);
224+
assert_eq!(body("jjlog"), None);
216225
}
217226
}

crates/jayjay-core/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ pub use jj_diff as diff;
22
pub use jj_diff::syntax;
33
pub mod dag;
44
pub mod file_tree;
5+
pub mod fuzzy;
56
pub mod hash;
67
mod jj_command;
8+
pub mod palette;
9+
pub mod placeholder;
710
mod repo;
811
pub mod review;
912
pub mod theme;

crates/jayjay-core/src/palette.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! Command palette history: dedupe newest-first with a cap, and cursor recall.
2+
3+
const HISTORY_LIMIT: usize = 20;
4+
5+
/// Recall outcome: the query to show, with `index` `None` for the live "jj " query past the newest entry.
6+
pub struct HistoryRecall {
7+
pub query: String,
8+
pub index: Option<usize>,
9+
}
10+
11+
/// Push `command` onto `history` newest-first, deduped, capped at the limit.
12+
pub fn record(command: &str, history: &[String]) -> Vec<String> {
13+
let command = command.trim();
14+
if command.is_empty() {
15+
return history.to_vec();
16+
}
17+
let mut values = vec![command.to_owned()];
18+
values.extend(
19+
history
20+
.iter()
21+
.filter(|value| value.as_str() != command)
22+
.cloned(),
23+
);
24+
values.truncate(HISTORY_LIMIT);
25+
values
26+
}
27+
28+
/// Walk the cursor one step (`older` toward older entries, else newer); `None` only when history is empty.
29+
pub fn recall(
30+
history: &[String],
31+
history_index: Option<usize>,
32+
older: bool,
33+
) -> Option<HistoryRecall> {
34+
if history.is_empty() {
35+
return None;
36+
}
37+
let index = if older {
38+
Some(
39+
history_index
40+
.map(|index| index.saturating_add(1))
41+
.unwrap_or(0)
42+
.min(history.len() - 1),
43+
)
44+
} else if let Some(index) = history_index
45+
&& index > 0
46+
{
47+
Some(index - 1)
48+
} else {
49+
None
50+
};
51+
Some(HistoryRecall {
52+
query: index
53+
.map(|index| format!("jj {}", history[index]))
54+
.unwrap_or_else(|| "jj ".to_owned()),
55+
index,
56+
})
57+
}
58+
59+
#[cfg(test)]
60+
mod tests {
61+
use super::*;
62+
63+
#[test]
64+
fn record_dedupes_and_keeps_newest_first() {
65+
let history = record("status", &[]);
66+
let history = record("log -r @", &history);
67+
let history = record("status", &history);
68+
69+
assert_eq!(history, vec!["status", "log -r @"]);
70+
}
71+
72+
#[test]
73+
fn record_caps_at_history_limit() {
74+
let mut history = Vec::new();
75+
for i in 0..(HISTORY_LIMIT + 5) {
76+
history = record(&format!("cmd {i}"), &history);
77+
}
78+
assert_eq!(history.len(), HISTORY_LIMIT);
79+
assert_eq!(history[0], format!("cmd {}", HISTORY_LIMIT + 4));
80+
}
81+
82+
#[test]
83+
fn recall_walks_older_and_newer_entries() {
84+
let history = vec!["status".to_owned(), "log -r @".to_owned()];
85+
86+
let first = recall(&history, None, true).expect("first recall");
87+
assert_eq!(first.query, "jj status");
88+
assert_eq!(first.index, Some(0));
89+
90+
let second = recall(&history, first.index, true).expect("second recall");
91+
assert_eq!(second.query, "jj log -r @");
92+
assert_eq!(second.index, Some(1));
93+
94+
let newer = recall(&history, second.index, false).expect("newer recall");
95+
assert_eq!(newer.query, "jj status");
96+
assert_eq!(newer.index, Some(0));
97+
98+
let live_query = recall(&history, newer.index, false).expect("live query recall");
99+
assert_eq!(live_query.query, "jj ");
100+
assert_eq!(live_query.index, None);
101+
}
102+
103+
#[test]
104+
fn recall_on_empty_history_returns_none() {
105+
assert!(recall(&[], None, true).is_none());
106+
}
107+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//! Classifies diff content as editable text vs. a synthetic placeholder the
2+
//! diff layer emits for content it cannot show inline. Single source of truth
3+
//! so the Rust diff-edit safety check and the Swift shell cannot drift apart;
4+
//! producers live in `repo/diff/materialize.rs` and `repo/diff/entry.rs`.
5+
6+
/// Every placeholder prefix the diff layer can emit. Keep in sync with the
7+
/// producers in `repo/diff` — a new placeholder must be listed here.
8+
const PLACEHOLDER_PREFIXES: &[&str] = &[
9+
"<binary file",
10+
"<directory>",
11+
"<git lfs ",
12+
"<git submodule",
13+
"<conflict",
14+
"<access denied",
15+
"<image ",
16+
];
17+
18+
/// True when `text` is editable text rather than a placeholder.
19+
pub fn is_editable_text(text: &str) -> bool {
20+
!PLACEHOLDER_PREFIXES
21+
.iter()
22+
.any(|prefix| text.starts_with(prefix))
23+
}
24+
25+
/// True when `text` is a Git LFS pointer/object placeholder.
26+
pub fn is_git_lfs_placeholder(text: &str) -> bool {
27+
text.starts_with("<git lfs ")
28+
}
29+
30+
/// True when `text` is a Git submodule placeholder.
31+
pub fn is_git_submodule_placeholder(text: &str) -> bool {
32+
text.starts_with("<git submodule")
33+
}
34+
35+
#[cfg(test)]
36+
mod tests {
37+
use super::*;
38+
39+
#[test]
40+
fn real_text_is_editable() {
41+
assert!(is_editable_text("let x = 1;"));
42+
assert!(is_editable_text(""));
43+
// A literal '<' that is not one of our placeholders stays editable.
44+
assert!(is_editable_text("<html>"));
45+
}
46+
47+
#[test]
48+
fn every_placeholder_is_non_editable() {
49+
for sample in [
50+
"<binary file (742800 bytes)>",
51+
"<directory>",
52+
"<git lfs pointer sha256:abc (10 bytes)>",
53+
"<git lfs object sha256:abc (10 bytes)>",
54+
"<git submodule deadbeef>",
55+
"<conflict>",
56+
"<access denied: permission>",
57+
"<image (100 bytes)>",
58+
] {
59+
assert!(
60+
!is_editable_text(sample),
61+
"should be non-editable: {sample}"
62+
);
63+
}
64+
}
65+
66+
#[test]
67+
fn lfs_and_submodule_classifiers() {
68+
assert!(is_git_lfs_placeholder(
69+
"<git lfs pointer sha256:abc (10 bytes)>"
70+
));
71+
assert!(!is_git_lfs_placeholder("<git submodule deadbeef>"));
72+
assert!(is_git_submodule_placeholder("<git submodule deadbeef>"));
73+
assert!(!is_git_submodule_placeholder(
74+
"<git lfs object sha256:abc (1 bytes)>"
75+
));
76+
}
77+
}

0 commit comments

Comments
 (0)