Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/dashboard-app/src/captures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,27 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) {
key(cx, tall, &["ctrl-enter"]);
save(cx, tall, out, &name("risk-conflict"));

// #87: a relative folder is refused and a missing one says so. A checkout listed since the dialog opened, typed
// with `..`, is found under its listed path and gets no second row.
let paths = Folder::new();
let (link, link_manage, _) = open_dialog(cx, &["read", "write:projects"], &paths);
click(cx, link, "project-row-2");
let press_link = |cx: &mut HeadlessAppContext, typed: &Path, state: &str| {
set(cx, link, &link_manage, "folder", &typed.to_string_lossy());
cx.update(|cx| link_manage.update(cx, |m, cx| m.link_checkout(cx)));
settle(cx);
save(cx, link, out, &name(state));
};
press_link(cx, Path::new("hs-buddy"), "link-relative");
press_link(cx, &paths.0.join("missing"), "link-missing");
let listed = paths.checkout("hs-buddy", "git@github.com:hemsoft-dev/hs-buddy.git");
dashboard_core::config::Config::add_repository(&paths.config_dir(), &listed).unwrap();
std::fs::create_dir_all(paths.0.join("sub")).unwrap();
let config = paths.config_dir().join("config.toml");
let before = std::fs::read(&config).unwrap();
press_link(cx, &paths.0.join("sub").join("..").join("hs-buddy"), "link-listed");
assert_eq!(std::fs::read(&config).unwrap(), before, "no second row");

// Frames for the recording, at 100% only: keyboard alone, adding a project and setting its priority.
if !suffix.is_empty() {
return;
Expand Down
28 changes: 26 additions & 2 deletions crates/dashboard-core/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ pub fn error_field(error: &WriteError) -> Option<&'static str> {
/// Why a checkout couldn't be linked to a project.
#[derive(Debug)]
pub enum LinkError {
/// The folder isn't a full path. A relative one would resolve against wherever the dashboard was started (#87).
Relative,
/// The folder doesn't exist or can't be opened.
Folder(std::io::Error),
/// The folder's origin is another repository, or none the dashboard can read.
Mismatch { found: Option<String> },
/// `config.toml` couldn't be read or written.
Expand All @@ -321,6 +325,11 @@ pub enum LinkError {
impl std::fmt::Display for LinkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Relative => f.write_str("Type the checkout's full path, not a relative folder; nothing was written."),
Self::Folder(error) if error.kind() == std::io::ErrorKind::NotFound => {
f.write_str("That folder doesn't exist; nothing was written.")
}
Self::Folder(error) => write!(f, "Can't open that folder ({error}); nothing was written."),
Self::Mismatch { found: Some(found) } => {
write!(f, "That folder's origin is {found}; nothing was written.")
}
Expand All @@ -334,13 +343,28 @@ impl std::fmt::Display for LinkError {
}

/// Adds `checkout` to `repositories` in the `config.toml` in `config_dir`, after checking its origin is `repo` with
/// the same identity rules the dashboard reads status with, and keeping the file's comments. A checkout already
/// listed is left as it is.
/// the same identity rules the dashboard reads status with, and keeping the file's comments.
///
/// The folder must be a full path; it is saved as typed (#87). A checkout already listed under any spelling of the
/// same folder (`..`, another letter case where the file system ignores case, a link) is left as it is and its listed
/// path returned, so it never gets a second row.
pub fn link_checkout(config_dir: &Path, checkout: &Path, repo: &str) -> Result<PathBuf, LinkError> {
if !checkout.is_absolute() {
return Err(LinkError::Relative);
}
let real = std::fs::canonicalize(checkout).map_err(LinkError::Folder)?;
let found = crate::pmd::checkout_identity(checkout).ok();
if !found.as_deref().is_some_and(|found| found.eq_ignore_ascii_case(repo)) {
return Err(LinkError::Mismatch { found });
}
let listed = Config::load(config_dir).map_err(LinkError::Config)?.repositories;
// Only full paths are compared on disk: a relative entry would resolve against the working folder.
if let Some(known) = listed
.into_iter()
.find(|known| known.is_absolute() && std::fs::canonicalize(known).is_ok_and(|known| known == real))
{
return Ok(known);
}
Config::add_repository(config_dir, checkout).map_err(LinkError::Config)?;
Ok(checkout.to_path_buf())
}
Expand Down
86 changes: 86 additions & 0 deletions crates/dashboard-core/tests/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,89 @@ fn a_checkout_of_another_repo_or_none_is_not_linked_and_nothing_is_written() {
));
assert_eq!(read(&dir), "repositories = []\n");
}

#[test]
fn a_relative_or_missing_folder_is_not_linked_and_nothing_is_written() {
let folder = Folder::new();
folder.checkout("tickdown", Some("https://github.com/hemsoft-dev/tickdown.git"));
let dir = folder.config("repositories = []\n");
// Refused before anything is read, so the result can't depend on the working folder (#87).
let mut relative = vec![PathBuf::from("tickdown"), PathBuf::from("./tickdown")];
if cfg!(windows) {
relative.extend([PathBuf::from(r"\tickdown"), PathBuf::from("C:tickdown")]);
}
for path in relative {
match projects::link_checkout(&dir, &path, "hemsoft-dev/tickdown") {
Err(error @ LinkError::Relative) => assert_eq!(
error.to_string(),
"Type the checkout's full path, not a relative folder; nothing was written."
),
other => panic!("{}: {other:?}", path.display()),
}
}
match projects::link_checkout(&dir, &folder.0.join("missing"), "hemsoft-dev/tickdown") {
Err(error @ LinkError::Folder(_)) => {
assert_eq!(error.to_string(), "That folder doesn't exist; nothing was written.")
}
other => panic!("{other:?}"),
}
assert_eq!(read(&dir), "repositories = []\n");
}

#[test]
fn another_spelling_of_a_listed_checkout_adds_no_second_row() {
let folder = Folder::new();
let tickdown = folder.checkout("tickdown", Some("https://github.com/hemsoft-dev/tickdown.git"));
std::fs::create_dir_all(folder.0.join("sub")).unwrap();
let text = format!(
"# My dashboard\nrepositories = [\n {:?}, # the widget\n]\n",
tickdown.to_string_lossy()
);
let dir = folder.config(&text);
let separator = std::path::MAIN_SEPARATOR;
let mut spellings = vec![
tickdown.clone(),
PathBuf::from(format!("{}{separator}", tickdown.display())),
folder.0.join("sub").join("..").join("tickdown"),
];
if cfg!(windows) {
// NTFS ignores letter case.
spellings.push(PathBuf::from(tickdown.to_string_lossy().to_uppercase()));
}
#[cfg(unix)]
{
let link = folder.0.join("link");
std::os::unix::fs::symlink(&tickdown, &link).unwrap();
spellings.push(link);
}
for spelling in spellings {
let linked = projects::link_checkout(&dir, &spelling, "hemsoft-dev/tickdown");
assert_eq!(linked.unwrap(), tickdown, "{}", spelling.display());
assert_eq!(read(&dir), text, "{}", spelling.display());
}
let config = Config::parse(&read(&dir), &dir.join("config.toml")).unwrap();
assert_eq!(config.repositories, [tickdown]);
}

#[test]
fn a_new_checkout_is_saved_as_typed_and_found_again_under_another_spelling() {
let folder = Folder::new();
let tickdown = folder.checkout("tickdown", Some("https://github.com/hemsoft-dev/tickdown.git"));
std::fs::create_dir_all(folder.0.join("sub")).unwrap();
let dir = folder.config("repositories = []\n");
let typed = folder.0.join("sub").join("..").join("tickdown");
assert_eq!(
projects::link_checkout(&dir, &typed, "hemsoft-dev/tickdown").unwrap(),
typed
);
let after = read(&dir);
// The comparison's resolved path never reaches the file: no `\\?\` prefix on Windows.
assert!(!after.contains(r"\\?\"), "{after}");
let config = Config::parse(&after, &dir.join("config.toml")).unwrap();
assert_eq!(config.repositories, std::slice::from_ref(&typed));
assert_eq!(
projects::link_checkout(&dir, &tickdown, "hemsoft-dev/tickdown").unwrap(),
typed
);
assert_eq!(read(&dir), after);
}
Loading