diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index c1a9af2f8..7f74b9eb9 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -146,6 +146,25 @@ pub async fn repo_commit( blocking(ctx, move |ctx| ctx.repo_commit(rid, sha, peer, revision)).await } +#[tauri::command] +pub async fn repo_commits_by_prefix( + ctx: tauri::State<'_, AppState>, + rid: RepoId, + prefix: String, +) -> Result, Error> { + blocking(ctx, move |ctx| ctx.repo_commits_by_prefix(rid, prefix)).await +} + +#[tauri::command] +pub async fn repo_split_tree_path( + ctx: tauri::State<'_, AppState>, + rid: RepoId, + peer: Option, + path: String, +) -> Result { + blocking(ctx, move |ctx| ctx.repo_split_tree_path(rid, peer, path)).await +} + #[tauri::command] pub fn seed(ctx: tauri::State<'_, AppState>, rid: RepoId) -> Result<(), Error> { ctx.seed(rid) diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index a8851357c..27232cb94 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -86,6 +86,8 @@ pub fn run() { repo::repo_by_id, repo::repo_commit_count, repo::repo_commit, + repo::repo_commits_by_prefix, + repo::repo_split_tree_path, repo::repo_count, repo::repo_readme, repo::repo_tree, diff --git a/crates/radicle-types/bindings/repo/TreePath.ts b/crates/radicle-types/bindings/repo/TreePath.ts new file mode 100644 index 000000000..a73027acf --- /dev/null +++ b/crates/radicle-types/bindings/repo/TreePath.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TreePath = { revision: string; path: string }; diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index 37040a586..f94cfa7f7 100644 --- a/crates/radicle-types/src/repo.rs +++ b/crates/radicle-types/src/repo.rs @@ -10,6 +10,15 @@ use radicle::{git, identity, issue, node, patch}; use crate::cobs::Author; use crate::error; +#[derive(Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct TreePath { + pub revision: String, + pub path: String, +} + #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index d868dd8a5..925d6b0db 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -21,6 +21,8 @@ use crate::repo; use crate::source; use crate::traits::Profile; +const COMMIT_PREFIX_LIMIT: usize = 10; + pub const MAX_BLOB_SIZE: usize = 10_485_760; #[derive(Serialize, Deserialize, PartialEq)] @@ -47,8 +49,8 @@ fn is_contributor(refs: &storage::SignedRefsInfo) -> bool { /// looked up under the peer's namespaced `refs/heads` and `refs/tags` when a /// peer is given, otherwise under the canonical top-level refs. With no /// revision, a peer resolves to its head of the project's default branch, and -/// no peer resolves to the canonical head. Raw commit OIDs are passed through -/// the handlers' `sha`/`head` argument instead of this function. +/// no peer resolves to the canonical head. A full commit OID resolves to itself, +/// unless it is Radicle's own bookkeeping rather than a commit of the project. fn resolve_revision( repo: &storage::git::Repository, peer: Option, @@ -60,6 +62,16 @@ fn resolve_revision( Some(commit.id().into()) }; + if let Some(oid) = revision + .as_deref() + .filter(|name| name.len() == 40) + .and_then(|name| name.parse::().ok()) + .filter(|oid| repo.backend.find_commit((*oid).into()).is_ok()) + .filter(|oid| !is_radicle_metadata(&repo.backend, *oid)) + { + return Ok(oid); + } + match peer { Some(peer) => { let name = match revision { @@ -905,6 +917,9 @@ pub trait Repo: Profile { let storage_repo = profile.storage.repository(rid)?; let oid = match sha { + Some(sha) if is_radicle_metadata(&storage_repo.backend, sha) => { + return Err(Error::RevisionNotFound(sha.to_string())); + } Some(sha) => sha, None => resolve_revision(&storage_repo, peer, revision)?, }; @@ -915,6 +930,98 @@ pub trait Repo: Profile { Ok(commit.into()) } + fn repo_commits_by_prefix( + &self, + rid: identity::RepoId, + prefix: String, + ) -> Result, Error> { + if !(4..=40).contains(&prefix.len()) || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) { + return Ok(Vec::new()); + } + + let profile = self.profile(); + let storage_repo = profile.storage.repository(rid)?; + + // `git rev-parse --disambiguate` lists every object with the prefix, + // which libgit2 cannot do: it only resolves a unique prefix. Falls back + // to that when the git binary is unavailable. + let oids = crate::binaries::git_command() + .and_then(|mut command| { + // An explicit `--git-dir` wins over a `GIT_DIR` the app may + // have inherited, which would otherwise pick the repository. + let mut git_dir = std::ffi::OsString::from("--git-dir="); + git_dir.push(storage_repo.path()); + command + .arg(git_dir) + .arg("rev-parse") + .arg(format!("--disambiguate={prefix}")); + + command + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect::>() + }) + }) + .unwrap_or_else(|| { + storage_repo + .backend + .find_commit_by_prefix(&prefix) + .map(|commit| vec![commit.id().into()]) + .unwrap_or_default() + }); + + let repo = surf::Repository::open(storage_repo.path())?; + + Ok(oids + .into_iter() + .filter(|oid| !is_radicle_metadata(&storage_repo.backend, *oid)) + .filter_map(|oid| repo.commit(oid).ok()) + .take(COMMIT_PREFIX_LIMIT) + .map(Into::into) + .collect()) + } + + fn repo_split_tree_path( + &self, + rid: identity::RepoId, + peer: Option, + path: String, + ) -> Result { + let profile = self.profile(); + let storage_repo = profile.storage.repository(rid)?; + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + for end in (1..=segments.len()).rev() { + let revision = segments[..end].join("/"); + if resolve_revision(&storage_repo, peer, Some(revision.clone())).is_ok() { + return Ok(repo::TreePath { + revision, + path: segments[end..].join("/"), + }); + } + } + + let DocAt { doc, .. } = storage_repo.identity_doc()?; + let revision = doc + .project() + .map_err(|e| Error::RevisionNotFound(e.to_string()))? + .default_branch() + .to_string(); + + Ok(repo::TreePath { + revision, + path: segments.join("/"), + }) + } + fn unseed(&self, rid: identity::RepoId) -> Result<(), Error> { let profile = self.profile(); let mut node = radicle::Node::new(profile.home().socket_from_env()); @@ -1004,3 +1111,155 @@ fn fetch_from_seeds( } } } + +fn is_radicle_metadata(repo: &git2::Repository, oid: git::Oid) -> bool { + repo.find_commit(oid.into()) + .and_then(|commit| commit.tree()) + .map(|tree| tree.get_name("manifest").is_some() || tree.get_name("signature").is_some()) + .unwrap_or(false) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use radicle::crypto::{Seed, SigningKey}; + use radicle::storage::{ReadRepository, ReadStorage}; + use radicle::test::fixtures; + + use crate::traits::repo::Repo; + use crate::{AppState, test}; + + #[test] + fn repo_commits_by_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("working"), &profile.storage, &signer).unwrap(); + let identity = profile + .storage + .repository(rid) + .unwrap() + .identity_head() + .unwrap(); + let state = AppState { profile }; + let head = head.to_string(); + + let found = state + .repo_commits_by_prefix(rid, head[..7].to_string()) + .unwrap(); + assert_eq!( + found + .iter() + .map(|commit| commit.id.to_string()) + .collect::>(), + vec![head.clone()] + ); + assert_eq!( + state + .repo_commits_by_prefix(rid, head.to_uppercase()) + .unwrap() + .len(), + 1 + ); + + assert!( + state + .repo_commits_by_prefix(rid, identity.to_string()[..7].to_string()) + .unwrap() + .is_empty() + ); + assert!( + state + .repo_commits_by_prefix(rid, head[..3].to_string()) + .unwrap() + .is_empty() + ); + assert!( + state + .repo_commits_by_prefix(rid, format!("{}z", &head[..6])) + .unwrap() + .is_empty() + ); + assert!( + state + .repo_commits_by_prefix(rid, format!("--{}", &head[..6])) + .unwrap() + .is_empty() + ); + } + + #[test] + fn repo_split_tree_path() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("working"), &profile.storage, &signer).unwrap(); + let backend = &profile.storage.repository(rid).unwrap().backend; + for name in [ + "refs/heads/feature/x", + "refs/tags/feature", + "refs/tags/v1.0", + ] { + backend.reference(name, head, false, "test").unwrap(); + } + let state = AppState { profile }; + let split = |path: &str| { + let split = state + .repo_split_tree_path(rid, None, path.to_string()) + .unwrap(); + (split.revision, split.path) + }; + + assert_eq!( + split("feature/x/src/lib.rs"), + ("feature/x".into(), "src/lib.rs".into()) + ); + assert_eq!(split("feature/y/src"), ("feature".into(), "y/src".into())); + assert_eq!(split("v1.0/README"), ("v1.0".into(), "README".into())); + assert_eq!( + split("docs/guide.md"), + ("master".into(), "docs/guide.md".into()) + ); + assert_eq!(split("master"), ("master".into(), "".into())); + } + + #[test] + fn repo_commit_accepts_an_oid_revision() { + let tmp = tempfile::tempdir().unwrap(); + let profile = test::profile(&tmp.path().join("home"), [0xff; 32]); + let signer = SigningKey::from_seed(Seed::new([0xff; 32])); + let (rid, _, _, head) = + fixtures::project(tmp.path().join("working"), &profile.storage, &signer).unwrap(); + let state = AppState { profile }; + + let commit = state + .repo_commit(rid, None, None, Some(head.to_string())) + .unwrap(); + assert_eq!(commit.id.to_string(), head.to_string()); + assert!( + state + .repo_commit(rid, None, None, Some("f".repeat(40))) + .is_err() + ); + let identity = state + .profile + .storage + .repository(rid) + .unwrap() + .identity_head() + .unwrap(); + assert!( + state + .repo_commit(rid, None, None, Some(identity.to_string())) + .is_err() + ); + assert!(state.repo_commit(rid, Some(identity), None, None).is_err()); + assert!( + state + .repo_commit(rid, Some(head.into()), None, None) + .is_ok() + ); + } +} diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index c2aa60be5..740d749f4 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -181,6 +181,11 @@ pub fn router(shared: Shared) -> Router { .route("/list_repo_commits", post(list_repo_commits_handler)) .route("/repo_commit_count", post(repo_commit_count_handler)) .route("/repo_commit", post(repo_commit_handler)) + .route( + "/repo_commits_by_prefix", + post(repo_commits_by_prefix_handler), + ) + .route("/repo_split_tree_path", post(repo_split_tree_path_handler)) .route("/list_issues", post(issues_handler)) .route("/issue_by_id", post(issue_handler)) .route("/comment_threads_by_issue_id", post(issue_threads_handler)) @@ -688,6 +693,40 @@ async fn repo_commit_handler( Ok::<_, Error>(Json(commit)) } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RepoCommitsByPrefixBody { + pub rid: identity::RepoId, + pub prefix: String, +} + +async fn repo_commits_by_prefix_handler( + Ctx(ctx): Ctx, + Json(RepoCommitsByPrefixBody { rid, prefix }): Json, +) -> impl IntoResponse { + let commits = ctx.repo_commits_by_prefix(rid, prefix)?; + + Ok::<_, Error>(Json(commits)) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RepoSplitTreePathBody { + pub rid: identity::RepoId, + #[serde(default)] + pub peer: Option, + pub path: String, +} + +async fn repo_split_tree_path_handler( + Ctx(ctx): Ctx, + Json(RepoSplitTreePathBody { rid, peer, path }): Json, +) -> impl IntoResponse { + let split = ctx.repo_split_tree_path(rid, peer, path)?; + + Ok::<_, Error>(Json(split)) +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IssuesBody { diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte index 8fca19189..6446f61b2 100644 --- a/src/components/ContextMenu.svelte +++ b/src/components/ContextMenu.svelte @@ -149,6 +149,37 @@ .menu:focus { outline: none; } + .menu :global(.menu-item) { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2rem; + padding: 0 0.75rem; + background: transparent; + border: 0; + border-radius: var(--border-radius-sm); + color: var(--color-text-primary); + font: var(--txt-body-m-regular); + text-align: left; + white-space: nowrap; + cursor: pointer; + } + .menu :global(.menu-item svg) { + color: var(--color-text-tertiary); + flex-shrink: 0; + } + .menu :global(.menu-item:hover) { + background-color: var(--color-surface-subtle); + } + .menu :global(.menu-item:hover svg) { + color: var(--color-text-primary); + } + .menu :global(.menu-separator) { + height: 1px; + margin: 0.25rem 0; + background-color: var(--color-border-subtle); + }
+ export interface MenuPosition { + x: number; + y: number; + target: HTMLElement; + } + + export function keepSelection(event: MouseEvent) { + if (event.button === 2) event.preventDefault(); + } + + export function menuPosition(event: MouseEvent): MenuPosition { + event.preventDefault(); + event.stopPropagation(); + window.getSelection()?.removeAllRanges(); + + return { + x: event.clientX, + y: event.clientY, + target: event.currentTarget as HTMLElement, + }; + } + + + + + + + + Open in {explorerHost(config)} + + + + + diff --git a/src/components/ExtendedTextarea.svelte b/src/components/ExtendedTextarea.svelte index 4446d23f1..5a79bf3be 100644 --- a/src/components/ExtendedTextarea.svelte +++ b/src/components/ExtendedTextarea.svelte @@ -1,4 +1,5 @@ + + + +{#if target.type === "node"} + + + +{:else if target.type === "repo"} + + + {#if resolvedRepoName} + + {:else} + + {/if} + + {repoName} + {#if missing} + + {truncateId(target.rid.replace("rad:", ""))} + + {/if} + +{:else if target.type === "commit"} + + + + + {commitSummary ?? fallback} + {#if commitSummary || missing} + {formatOid(target.oid)} + {/if} + +{:else if target.kind === "issue"} + + + + + {issue?.title ?? fallback} + {#if missing} + {formatOid(target.oid)} + {/if} + +{:else} + + + + + {patch?.title ?? fallback} + {#if missing} + {formatOid(target.oid)} + {/if} + +{/if} + +{#if menu && explorerHref && config} + (menu = undefined)} /> +{/if} diff --git a/src/components/MentionAutocomplete.svelte b/src/components/MentionAutocomplete.svelte new file mode 100644 index 000000000..fa2473a6f --- /dev/null +++ b/src/components/MentionAutocomplete.svelte @@ -0,0 +1,815 @@ + + + + + + +{#if open} + +
{ + event.preventDefault(); + }}> +
+ {#each suggestions as suggestion, index (suggestion.key)} + + {/each} +
+
+{/if} diff --git a/src/components/NodeId.svelte b/src/components/NodeId.svelte index 07ccf5de6..47a63a472 100644 --- a/src/components/NodeId.svelte +++ b/src/components/NodeId.svelte @@ -3,13 +3,9 @@ import type { ComponentProps } from "svelte"; import { cachedAlias, cachedConfig, writeToClipboard } from "@app/lib/invoke"; + import { entityUrl } from "@app/lib/mentions"; import { getPatchActivitySource } from "@app/lib/patchActivityContext"; - import { - didFromPublicKey, - explorerUrl, - pluralize, - truncateId, - } from "@app/lib/utils"; + import { didFromPublicKey, pluralize, truncateId } from "@app/lib/utils"; import Icon from "@app/components/Icon.svelte"; import Popover from "@app/components/Popover.svelte"; @@ -457,7 +453,7 @@ /`), which is // the form the explorer resolves; `?revision=` does not navigate there. - await writeToClipboard( - explorerUrl(`${rid}/patches/${patchId}/${op.id}?tab=changes`, config), + const url = entityUrl( + { type: "cob", kind: "patch", rid, oid: patchId }, + config, ); + if (!url) return; + await writeToClipboard(`${url}/${op.id}?tab=changes`); copyLinkIcon = "checkmark"; setTimeout(() => (copyLinkIcon = "link"), 1000); } diff --git a/src/components/ReferenceLink.svelte b/src/components/ReferenceLink.svelte new file mode 100644 index 000000000..d9b73497b --- /dev/null +++ b/src/components/ReferenceLink.svelte @@ -0,0 +1,107 @@ + + + + + + {#if icon} + + {/if}{label} + + +{#if menu && url && config} + (menu = undefined)} /> +{/if} diff --git a/src/components/RepoHeader.svelte b/src/components/RepoHeader.svelte index 9784d2a69..c3d0777db 100644 --- a/src/components/RepoHeader.svelte +++ b/src/components/RepoHeader.svelte @@ -154,7 +154,7 @@
["name"]; interface Props { - explorerPath: string; + target: Entity; id: string; // The noun the id represents, e.g. "issue", so "copy id" reads "Copy issue ID". idLabel: string; @@ -31,7 +33,7 @@ } const { - explorerPath, + target, id, idLabel, config, @@ -39,7 +41,7 @@ variant = "ghost", }: Props = $props(); - const url = $derived(explorerUrl(explorerPath, config)); + const url = $derived(entityUrl(target, config) ?? ""); const host = $derived(explorerHost(config)); const actions = $derived< diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index aa7355c14..08f4040c1 100644 --- a/src/components/SidebarRepoList.svelte +++ b/src/components/SidebarRepoList.svelte @@ -72,6 +72,7 @@ invoke, writeToClipboard, } from "@app/lib/invoke"; + import { entityUrl } from "@app/lib/mentions"; import { show } from "@app/lib/modal"; import { repoListScope } from "@app/lib/repoListScope"; import * as router from "@app/lib/router"; @@ -81,11 +82,7 @@ useShortcuts, } from "@app/lib/shortcuts.svelte"; import { sidebarCollapsed } from "@app/lib/sidebar.svelte"; - import { - explorerHost, - explorerUrl, - formatRepositoryId, - } from "@app/lib/utils"; + import { explorerHost, formatRepositoryId } from "@app/lib/utils"; import AddRepoButton from "@app/components/AddRepoButton.svelte"; import ContextMenu from "@app/components/ContextMenu.svelte"; @@ -912,38 +909,6 @@ opacity: 1; transform: rotate(0); } - - .menu-item { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - min-height: 2rem; - padding: 0 0.75rem; - background: transparent; - border: 0; - border-radius: var(--border-radius-sm); - color: var(--color-text-primary); - font: var(--txt-body-m-regular); - text-align: left; - white-space: nowrap; - cursor: pointer; - } - .menu-item :global(svg) { - color: var(--color-text-tertiary); - flex-shrink: 0; - } - .menu-item:hover { - background-color: var(--color-surface-subtle); - } - .menu-item:hover :global(svg) { - color: var(--color-text-primary); - } - .menu-separator { - height: 1px; - margin: 0.25rem 0; - background-color: var(--color-border-subtle); - } {#if seededNotReplicated.length > 0 && !sidebarCollapsed.value} @@ -1263,7 +1228,7 @@ {#if contextMenu} {@const repo = contextMenu.repo} - {@const url = explorerUrl(repo.rid, config)} + {@const url = entityUrl({ type: "repo", rid: repo.rid }, config) ?? ""} ; focus?: boolean; oninput?: FormEventHandler; + interceptKeydown?: (event: KeyboardEvent) => boolean; onkeypress?: FormEventHandler; placeholder?: string; selectionEnd?: number; @@ -36,9 +38,11 @@ let { draggingOver, borderVariant = "float", + element = $bindable(undefined), focus = false, onpaste, oninput, + interceptKeydown, onkeypress, placeholder = undefined, // Defaulting selectionStart and selectionEnd to 0, since no full support yet. @@ -58,19 +62,12 @@ float: "var(--color-border-subtle)", }; - let textareaElement: HTMLTextAreaElement | undefined = $state(undefined); let focussed = $state(false); onMount(() => { - if (textareaElement) { - // The selectionchange event listener doesn't modify the selection on Enter. - textareaElement.addEventListener("keydown", (event: KeyboardEvent) => { - if (event.key === "Enter") { - selectionStart += 1; - selectionEnd += 1; - } - }); - textareaElement.addEventListener("selectionchange", (event: Event) => { + if (element) { + element.addEventListener("keydown", handleKeydown); + element.addEventListener("selectionchange", (event: Event) => { if ( event.target && "selectionStart" in event.target && @@ -87,30 +84,30 @@ // options are mutually exclusive because a user resized textarea would // automatically shrink upon text input otherwise. $effect(() => { - if (textareaElement && size === "grow") { + if (element && size === "grow") { // React to changes to the textarea content. // eslint-disable-next-line @typescript-eslint/no-unused-expressions value; // Reset height to 0px on every value change so that the textarea // immediately shrinks when all text is deleted. - textareaElement.style.height = `0px`; - textareaElement.style.height = `${textareaElement.scrollHeight}px`; + element.style.height = `0px`; + element.style.height = `${element.scrollHeight}px`; } }); $effect(() => { - if (textareaElement && focus) { - textareaElement.focus(); + if (element && focus) { + element.focus(); focus = false; } }); $effect(() => { void tick().then(() => { - if (textareaElement && focus) { - textareaElement.setSelectionRange(selectionStart, selectionEnd); - textareaElement.focus(); + if (element && focus) { + element.setSelectionRange(selectionStart, selectionEnd); + element.focus(); } }); }); @@ -158,23 +155,33 @@ } function handleKeydown(event: KeyboardEvent) { + if (interceptKeydown?.(event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (matchesShortcut(event, "submit")) { void submit(); } if (event.key === "Escape") { - textareaElement?.blur(); + element?.blur(); + } + if (event.key === "Enter") { + selectionStart += 1; + selectionEnd += 1; } const format = formats.find(f => matchesShortcut(event, f)); - if (format && textareaElement) { + if (format && element) { event.preventDefault(); applyEdit( - textareaElement, + element, applyMarkdownFormat( format, - textareaElement.value, - textareaElement.selectionStart, - textareaElement.selectionEnd, + element.value, + element.selectionStart, + element.selectionEnd, ), ); } @@ -251,7 +258,7 @@ style:min-height={styleMinHeight} style:padding={stylePadding} tabindex="0" - bind:this={textareaElement} + bind:this={element} bind:value aria-label="textarea-comment" class="txt-body-m-regular" @@ -264,8 +271,7 @@ {oninput} {onkeypress} onfocus={() => (focussed = true)} - onblur={() => (focussed = false)} - onkeydown={handleKeydown}> + onblur={() => (focussed = false)}> {#if draggingOver}
diff --git a/src/lib/cached.ts b/src/lib/cached.ts index 182f457ba..6565b6f20 100644 --- a/src/lib/cached.ts +++ b/src/lib/cached.ts @@ -10,6 +10,7 @@ export function cached( f: (...args: Args) => Promise, makeKey: (...args: Args) => string, options?: LRUCache.Options }, unknown>, + missTtl?: number, ): Cached { const cache = new LRUCache(options || { max: 500 }); const fn = function (...args: Args): Promise { @@ -19,9 +20,18 @@ export function cached( const promise = f(...args); // Evict on rejection so the next caller can retry with a fresh request. - promise.catch(() => { - cache.delete(key); - }); + promise.then( + value => { + if (missTtl !== undefined && (value === null || value === undefined)) { + const entry = cache.get(key); + if (entry?.promise === promise) + cache.set(key, entry, { ttl: missTtl }); + } + }, + () => { + cache.delete(key); + }, + ); cache.set(key, { promise }); return promise; } as Cached; diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 8e6caa6c9..35b46f0d6 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -1,10 +1,16 @@ +import type { AliasSuggestion } from "@bindings/cob/AliasSuggestion"; import type { DiffOptions } from "@bindings/cob/DiffOptions"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; +import type { Patch } from "@bindings/cob/patch/Patch"; import type { Config } from "@bindings/config/Config"; import type { Diff } from "@bindings/diff/Diff"; import type { Stats } from "@bindings/diff/Stats"; import type { Commit } from "@bindings/repo/Commit"; import type { Job } from "@bindings/repo/Job"; +import type { RepoInfo } from "@bindings/repo/RepoInfo"; import type { RepoSummary } from "@bindings/repo/RepoSummary"; +import type { TreePath } from "@bindings/repo/TreePath"; import * as tauri from "@tauri-apps/api/core"; @@ -167,6 +173,134 @@ export const cachedAlias = cached(alias, (...[nid]) => `alias:${nid}`, { max: 5_000, }); +async function searchAliases(query: string): Promise { + return withTestBackend(tauri.invoke, "search_aliases", { + query: query === "" ? undefined : query, + }); +} + +export const cachedSearchAliases = cached( + searchAliases, + (...[query]) => `search_aliases:${query}`, + { max: 200, ttl: 60_000 }, +); + +const MENTION_CANDIDATE_TAKE = 200; + +async function listIssueCandidates(rid: string): Promise { + const issues = await invoke>("list_issues", { + rid, + status: undefined, + skip: 0, + take: MENTION_CANDIDATE_TAKE, + }); + + return issues.content; +} + +export const cachedListIssueCandidates = cached( + listIssueCandidates, + (...[rid]) => `issue_candidates:${rid}`, + { max: 10, ttl: 30_000 }, +); + +async function listPatchCandidates(rid: string): Promise { + const patches = await invoke>("list_patches", { + rid, + status: undefined, + skip: 0, + take: MENTION_CANDIDATE_TAKE, + }); + + return patches.content; +} + +export const cachedListPatchCandidates = cached( + listPatchCandidates, + (...[rid]) => `patch_candidates:${rid}`, + { max: 10, ttl: 30_000 }, +); + +const missTtl = 5_000; + +async function repoById(rid: string): Promise { + return withTestBackend(tauri.invoke, "repo_by_id", { rid }); +} + +export const cachedRepoById = cached( + repoById, + (...[rid]) => `repo_by_id:${rid}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function issueById(rid: string, id: string): Promise { + return withTestBackend(tauri.invoke, "issue_by_id", { rid, id }); +} + +export const cachedIssueById = cached( + issueById, + (...[rid, id]) => `issue_by_id:${rid}:${id}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function patchById(rid: string, id: string): Promise { + return withTestBackend(tauri.invoke, "patch_by_id", { rid, id }); +} + +export const cachedPatchById = cached( + patchById, + (...[rid, id]) => `patch_by_id:${rid}:${id}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function repoCommit(rid: string, sha: string): Promise { + return withTestBackend(tauri.invoke, "repo_commit", { rid, sha }); +} + +export const cachedRepoCommit = cached( + repoCommit, + (...[rid, sha]) => `repo_commit:${rid}:${sha}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function repoCommitsByPrefix( + rid: string, + prefix: string, +): Promise { + return withTestBackend(tauri.invoke, "repo_commits_by_prefix", { + rid, + prefix, + }); +} + +export const cachedRepoCommitsByPrefix = cached( + repoCommitsByPrefix, + (...[rid, prefix]) => `repo_commits_by_prefix:${rid}:${prefix}`, + { max: 200, ttl: 60_000 }, +); + +async function repoSplitTreePath( + rid: string, + peer: string | undefined, + path: string, +): Promise { + return withTestBackend(tauri.invoke, "repo_split_tree_path", { + rid, + peer, + path, + }); +} + +export const cachedRepoSplitTreePath = cached( + repoSplitTreePath, + (...[rid, peer, path]) => `repo_split_tree_path:${rid}:${peer}:${path}`, + { max: 50, ttl: 60_000 }, +); + async function config(): Promise { return withTestBackend(tauri.invoke, "config", {}); } diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index f677c9676..6109d2523 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,4 +1,9 @@ -import type { MarkedExtension, Tokens } from "marked"; +import type { + MarkedExtension, + RendererExtension, + TokenizerExtension, + Tokens, +} from "marked"; import dompurify from "dompurify"; import escape from "lodash/escape.js"; @@ -9,9 +14,20 @@ import katexMarkedExtension from "marked-katex-extension"; import markedLinkifyIt from "marked-linkify-it"; import emojis from "@app/lib/emojis"; +import { bareReferenceStart, matchBareReference } from "@app/lib/mentions"; + +// DOMPurify only keeps hrefs whose scheme it recognises, and drops `rad:` and +// `did:key:` links along with the rest. Neither can execute the way +// `javascript:` can, and the app replaces every such anchor with a component +// after sanitization, so none is ever followed as a raw href. +// This is DOMPurify's own default as of 3.4, with those two schemes added, so +// it needs revisiting when DOMPurify changes its default. +const allowedUriSchemes = + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix|rad|did):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i; dompurify.setConfig({ /* eslint-disable @typescript-eslint/naming-convention */ + ALLOWED_URI_REGEXP: allowedUriSchemes, ALLOWED_ATTR: [ "align", "checked", @@ -81,6 +97,38 @@ const anchorMarkedExtension = { renderer: (token: Tokens.Generic): string => ``, }; +// Past this many references in one document the rest stay text, matching +// the number of chips `Markdown` mounts. +export const maximumReferences = 200; +const referenceCounts = new WeakMap(); + +const radicleReferenceMarkedExtension: TokenizerExtension & RendererExtension = + { + name: "radicleReference", + level: "inline", + start(src: string) { + if ((referenceCounts.get(this.lexer) ?? 0) >= maximumReferences) return; + return bareReferenceStart(src); + }, + tokenizer(src: string) { + if (this.lexer.state.inLink) return; + const match = matchBareReference(src); + if (!match) return; + referenceCounts.set( + this.lexer, + (referenceCounts.get(this.lexer) ?? 0) + 1, + ); + + return { + type: "radicleReference", + raw: match.raw, + text: match.raw, + }; + }, + renderer: (token: Tokens.Generic): string => + `${escape(token.text)}`, + }; + export class Renderer extends BaseRenderer { /** * If `baseUrl` is provided, all hrefs attributes in anchor tags, except those @@ -125,6 +173,6 @@ export const markdownWithExtensions = new Marked( markedFootnote({ refMarkers: true }), markedEmoji({ emojis }), ((): MarkedExtension => ({ - extensions: [anchorMarkedExtension], + extensions: [anchorMarkedExtension, radicleReferenceMarkedExtension], }))(), ); diff --git a/src/lib/mentionTrigger.ts b/src/lib/mentionTrigger.ts new file mode 100644 index 000000000..3be961b7b --- /dev/null +++ b/src/lib/mentionTrigger.ts @@ -0,0 +1,175 @@ +import type { Entity } from "@app/lib/mentions"; +import { + describeLink, + parseBareIdentifier, + parseEntityHref, +} from "@app/lib/mentions"; +import type { RadReference } from "@app/lib/radUri"; +import { + isOid, + parseExplorerTreeUrl, + parseExplorerUrl, + parseReference, +} from "@app/lib/radUri"; + +export type MentionScope = "all" | "cobs"; + +export type MentionTrigger = + | { + kind: "query"; + scope: MentionScope; + start: number; + end: number; + query: string; + } + | { + kind: "identifier"; + start: number; + end: number; + target: Entity; + } + | { + kind: "oid"; + start: number; + end: number; + oid: string; + } + | { + kind: "link"; + start: number; + end: number; + reference: RadReference; + } + | { + kind: "tree"; + start: number; + end: number; + rid: string; + namespace?: string; + path: string; + fragment?: string; + }; + +const openingCharacters = new Set([" ", "\n", "\t", "(", "[", ">", '"', "'"]); + +const queryPattern = /^[\w.-]*$/; + +const maximumQueryLength = 64; + +const identifierPrefixes = ["did:key:", "rad:", "https://", "http://"]; + +const maximumIdentifierLength = 512; + +export function findMentionTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + return ( + findQueryTrigger(text, caret) ?? + findIdentifierTrigger(text, caret) ?? + findBareTrigger(text, caret) + ); +} + +function findBareTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + let start = caret; + while (start > 0 && /[0-9A-Za-z]/.test(text[start - 1])) { + start--; + } + const token = text.slice(start, caret); + if (token === "") return undefined; + if (start > 0 && !openingCharacters.has(text[start - 1])) return undefined; + + if (isOid(token)) { + return { kind: "oid", start, end: caret, oid: token.toLowerCase() }; + } + + const target = parseBareIdentifier(token); + + return target ? { kind: "identifier", start, end: caret, target } : undefined; +} + +function findQueryTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + for (let index = caret - 1; index >= 0; index--) { + const character = text[index]; + + if (character === "@" || character === "#") { + const preceding = index === 0 ? " " : text[index - 1]; + if (!openingCharacters.has(preceding)) return undefined; + + const query = text.slice(index + 1, caret); + if (!queryPattern.test(query)) return undefined; + + const scope: MentionScope = character === "@" ? "all" : "cobs"; + + return { kind: "query", scope, start: index, end: caret, query }; + } + + if (!queryPattern.test(character)) return undefined; + if (caret - index > maximumQueryLength) return undefined; + } + + return undefined; +} + +function findIdentifierTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + let wordStart = caret; + while ( + wordStart > 0 && + !/\s/.test(text[wordStart - 1]) && + caret - wordStart < maximumIdentifierLength + ) { + wordStart--; + } + const word = text.slice(wordStart, caret); + if (word === "") return undefined; + + const candidates = identifierPrefixes + .map(prefix => ({ prefix, offset: word.indexOf(prefix) })) + .filter(({ offset }) => offset !== -1) + .sort((a, b) => a.offset - b.offset); + + for (const { prefix, offset } of candidates) { + const trigger = triggerFor(prefix, word.slice(offset)); + if (trigger) return { ...trigger, start: wordStart + offset, end: caret }; + } + + return undefined; +} + +type Found = DistributiveOmit; +type DistributiveOmit = T extends unknown + ? Omit + : never; + +function triggerFor(prefix: string, text: string): Found | undefined { + const target = parseEntityHref(text); + if (target) return { kind: "identifier", target }; + + const reference = + prefix === "rad:" ? parseReference(text) : parseExplorerUrl(text); + if (reference && describeLink(reference, "") !== undefined) { + return { kind: "link", reference }; + } + if (prefix === "rad:") return undefined; + + const tree = parseExplorerTreeUrl(text); + return ( + tree && { + kind: "tree", + rid: `rad:${tree.repo}`, + namespace: tree.namespace, + path: tree.path, + fragment: tree.fragment, + } + ); +} diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts new file mode 100644 index 000000000..c809e06e2 --- /dev/null +++ b/src/lib/mentions.ts @@ -0,0 +1,325 @@ +import type Icon from "@app/components/Icon.svelte"; +import type { RepoRoute } from "@app/views/repo/router"; +import type { Config } from "@bindings/config/Config"; +import type { ComponentProps } from "svelte"; + +import type { RadReference } from "@app/lib/radUri"; +import { + cobListType, + filePath, + formatReference, + isNodeId, + isOid, + isRepoId, + isSafeRef, + issueType, + parseExplorerUrl, + parseReference, + patchType, + releaseType, +} from "@app/lib/radUri"; +import { explorerLink, formatOid, truncateId } from "@app/lib/utils"; + +export type Entity = + | { type: "node"; nid: string } + | { type: "repo"; rid: string } + | { type: "cob"; kind: "issue" | "patch"; rid: string; oid: string } + | { type: "commit"; rid: string; oid: string }; + +export function toEntity(reference: RadReference): Entity | undefined { + if (reference.type === "did") return { type: "node", nid: reference.node }; + + const { repo, resource, namespace, query, fragment } = reference.uri; + if (namespace || query || fragment !== undefined) return undefined; + const rid = `rad:${repo}`; + if (!resource) return { type: "repo", rid }; + + if (resource.type === "commit" && isOid(resource.ref)) { + return { type: "commit", rid, oid: resource.ref }; + } + if (resource.type === "cob" && resource.oid !== undefined) { + if (resource.typeName === issueType) { + return { type: "cob", kind: "issue", rid, oid: resource.oid }; + } + if (resource.typeName === patchType) { + return { type: "cob", kind: "patch", rid, oid: resource.oid }; + } + } + + return undefined; +} + +export function toRadReference(target: Entity): RadReference { + if (target.type === "node") return { type: "did", node: target.nid }; + + const repo = target.rid.replace(/^rad:/, ""); + switch (target.type) { + case "repo": + return { type: "uri", uri: { repo } }; + case "commit": + return { + type: "uri", + uri: { repo, resource: { type: "commit", ref: target.oid } }, + }; + case "cob": + return { + type: "uri", + uri: { + repo, + resource: { + type: "cob", + typeName: target.kind === "issue" ? issueType : patchType, + oid: target.oid, + }, + }, + }; + } +} + +export function parseEntityHref(href: string): Entity | undefined { + const trimmed = href.trim(); + const reference = parseReference(trimmed) ?? parseExplorerUrl(trimmed); + + return reference ? toEntity(reference) : undefined; +} + +export function parseBareIdentifier(token: string): Entity | undefined { + if (isNodeId(token)) return { type: "node", nid: token }; + if (isRepoId(token)) return { type: "repo", rid: `rad:${token}` }; + + return undefined; +} + +const maximumReferenceLength = 512; + +const bareReferencePattern = + /^(?:did:key:|rad:)[A-Za-z0-9._~!$&'()*+,;=:@/?#%-]{1,513}/; + +const prosePunctuation = new Set(["'", '"', ",", ";", "!", "?", "(", ")", "*"]); + +function isAlphanumeric(character: string): boolean { + return /[0-9A-Za-z]/.test(character); +} + +// Only the start of a valid reference counts: the markdown lexer copies the +// rest of the input at every start it is given, so offering each `rad:` of a +// long hostile run would make rendering quadratic. +export function bareReferenceStart(src: string): number | undefined { + const candidate = /(^|[^0-9A-Za-z])(?:did:key:z|rad:)/g; + for (let match = candidate.exec(src); match; match = candidate.exec(src)) { + const index = match.index + match[1].length; + if (matchBareReference(src.slice(index, index + maximumReferenceLength))) { + return index; + } + candidate.lastIndex = index + 1; + } + + return undefined; +} + +export function matchBareReference( + src: string, +): { raw: string; reference: RadReference } | undefined { + const raw = bareReferencePattern.exec(src)?.[0]; + // Each retry below parses again, so an unbounded run of punctuation in a + // comment would take quadratic time to render. + if (!raw || raw.length > maximumReferenceLength) return undefined; + + let end = raw.length; + for (;;) { + while (end > 0 && !isAlphanumeric(raw[end - 1])) end--; + const candidate = raw.slice(0, end); + const reference = parseReference(candidate); + if (reference) return { raw: candidate, reference }; + + do end--; + while (end > 0 && !prosePunctuation.has(raw[end])); + if (end <= 0) return undefined; + } +} + +export function referenceRoute(reference: RadReference): RepoRoute | undefined { + if (reference.type !== "uri") return undefined; + const { repo, namespace, resource } = reference.uri; + if (resource && "ref" in resource && !isSafeRef(resource.ref)) { + return undefined; + } + const rid = `rad:${repo}`; + + const file = filePath(reference); + if (file) { + return { + resource: "repo.home", + rid, + peer: namespace, + revision: shortRevision(file.revision), + path: file.path || undefined, + }; + } + + if (!resource) return { resource: "repo.home", rid, peer: namespace }; + if (resource.type === "commit" && isOid(resource.ref)) { + return { resource: "repo.commit", rid, commit: resource.ref }; + } + if (resource.type === "cob" && resource.oid !== undefined) { + if (resource.typeName === issueType) { + return { + resource: "repo.issue", + rid, + issue: resource.oid, + status: "all", + }; + } + if (resource.typeName === patchType) { + return { + resource: "repo.patch", + rid, + patch: resource.oid, + status: undefined, + reviewId: undefined, + }; + } + } + if (resource.type === "commit" || resource.type === "tag") { + return { + resource: "repo.home", + rid, + peer: namespace, + revision: shortRevision(resource.ref), + }; + } + + const typeName = cobListType(reference); + if (typeName === issueType) { + return { resource: "repo.issues", rid, status: "all" }; + } + if (typeName === patchType) { + return { resource: "repo.patches", rid, status: undefined }; + } + + return undefined; +} + +export interface LinkDescription { + label: string; + primary: string; + secondary: string; + icon: ComponentProps["name"]; +} + +const cobLists: Record< + string, + { label: string; icon: LinkDescription["icon"] } +> = { + [issueType]: { label: "Issues", icon: "issue" }, + [patchType]: { label: "Patches", icon: "patch" }, + [releaseType]: { label: "Releases", icon: "archive" }, +}; + +export function describeLink( + reference: RadReference, + repoName: string, + peerName?: string, +): LinkDescription | undefined { + if (reference.type !== "uri") return undefined; + const { resource, namespace, fragment } = reference.uri; + if (resource && "ref" in resource && !isSafeRef(resource.ref)) { + return undefined; + } + + const file = filePath(reference); + if (file) { + const revision = shortRevision(file.revision); + const shown = isOid(revision) ? formatOid(revision) : revision; + if (!file.path) { + return { + label: `${repoName}: ${shown}`, + primary: shown, + secondary: repoName, + icon: "folder", + }; + } + const line = fragment ? `#${fragment}` : ""; + return { + label: `${repoName}: ${file.path}${line}`, + primary: `${file.path}${line}`, + secondary: shown, + icon: "document", + }; + } + + const typeName = cobListType(reference); + const list = typeName ? cobLists[typeName] : undefined; + if (list) { + return { + label: `${repoName}: ${list.label.toLowerCase()}`, + primary: list.label, + secondary: repoName, + icon: list.icon, + }; + } + + if ( + resource?.type === "cob" && + resource.typeName === releaseType && + resource.oid !== undefined + ) { + const release = `release ${formatOid(resource.oid)}`; + return { + label: `${repoName}: ${release}`, + primary: `Release ${formatOid(resource.oid)}`, + secondary: repoName, + icon: "archive", + }; + } + + if ( + (resource?.type === "commit" && !isOid(resource.ref)) || + resource?.type === "tag" + ) { + const revision = shortRevision(resource.ref); + return { + label: `${repoName}: ${revision}`, + primary: revision, + secondary: repoName, + icon: resource.type === "tag" ? "label" : "branch", + }; + } + + if (!resource && namespace) { + const peer = peerName ?? truncateId(namespace); + return { + label: `${repoName}: ${peer}`, + primary: peer, + secondary: repoName, + icon: "repository", + }; + } + + return undefined; +} + +function shortRevision(ref: string): string { + return ref.replace(/^refs\/(?:heads|tags)\//, ""); +} + +export function entityUri(target: Entity): string { + return formatReference(toRadReference(target)); +} + +export function entityUrl(target: Entity, config: Config): string | undefined { + return explorerLink(toRadReference(target), config); +} + +export function entityMarkdown(target: Entity, label: string): string { + return referenceMarkdown(toRadReference(target), label); +} + +export function referenceMarkdown( + reference: RadReference, + label: string, +): string { + const escaped = label.replace(/[[\]\\]/g, character => `\\${character}`); + + return `[${escaped}](${formatReference(reference)})`; +} diff --git a/src/lib/radUri.ts b/src/lib/radUri.ts index 5ef400e7f..855d4a9ff 100644 --- a/src/lib/radUri.ts +++ b/src/lib/radUri.ts @@ -273,12 +273,12 @@ function queryPath(uri: RadUri): string | undefined { return uri.query?.find( ({ param, value }) => (param === "path" || param === "tree" || param === "blob") && - value !== undefined && - value !== "", + value !== undefined, )?.value; } function explorerRevision(ref: string): string | undefined { + if (!isSafeRef(ref)) return undefined; if (!ref.startsWith("refs/")) return ref; for (const prefix of ["refs/heads/", "refs/tags/"]) { if (ref.startsWith(prefix)) return ref.slice(prefix.length); @@ -287,6 +287,33 @@ function explorerRevision(ref: string): string | undefined { return undefined; } +// A `.` or `..` segment would walk the explorer URL to a page the reference +// does not name, so such a path or ref has no explorer page. +// The rules of `git check-ref-format` that the URI grammar does not already +// enforce; a `..` segment would otherwise walk a link to another page. +export function isSafeRef(ref: string): boolean { + return ref + .split("/") + .every( + segment => + !segment.startsWith(".") && + !segment.endsWith(".") && + !segment.endsWith(".lock") && + !segment.includes(".."), + ); +} + +function hasDotSegment(path: string): boolean { + return path.split("/").some(segment => segment === "." || segment === ".."); +} + +function explorerFilePath(path: string): string | undefined { + const segments = path.split("/").map(decodeSegment); + if (hasDotSegment(segments.join("/"))) return undefined; + + return segments.map(encodeURIComponent).join("/"); +} + function explorerResourcePath(uri: RadUri): string | undefined { const { resource } = uri; const remote = uri.namespace ? `/remotes/${uri.namespace}` : ""; @@ -297,8 +324,12 @@ function explorerResourcePath(uri: RadUri): string | undefined { case "tag": { const revision = explorerRevision(resource.ref); if (revision === undefined) return undefined; - const path = queryPath(uri); - if (path !== undefined) return `${remote}/tree/${revision}/${path}`; + const raw = queryPath(uri); + if (raw !== undefined) { + const path = explorerFilePath(raw); + if (path === undefined) return undefined; + return `${remote}/tree/${revision}${path ? `/${path}` : ""}`; + } if (resource.type === "commit" && isOid(revision)) { return `/commits/${revision}`; } @@ -330,7 +361,12 @@ export function explorerUrl( const path = explorerResourcePath(reference.uri); if (path === undefined) return undefined; - return `${node}/rad:${reference.uri.repo}${path}`; + const fragment = + isFileReference(reference) && reference.uri.fragment + ? `#${reference.uri.fragment}` + : ""; + + return `${node}/rad:${reference.uri.repo}${path}${fragment}`; } function decodeSegment(segment: string): string { @@ -341,7 +377,9 @@ function decodeSegment(segment: string): string { } } -export function parseExplorerUrl(href: string): RadReference | undefined { +function explorerUrlParts( + href: string, +): { segments: string[]; fragment?: string } | undefined { let url: URL; try { url = new URL(href); @@ -350,10 +388,46 @@ export function parseExplorerUrl(href: string): RadReference | undefined { } if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; - const segments = url.pathname - .split("/") - .filter(segment => segment !== "") - .map(decodeSegment); + const fragment = url.hash.slice(1); + + return { + segments: url.pathname + .split("/") + .filter(segment => segment !== "") + .map(decodeSegment), + fragment: + fragment && queryOrFragmentPattern.test(fragment) ? fragment : undefined, + }; +} + +function explorerRepoPage( + segments: string[], +): { repo: string; namespace?: string; tail: string[] } | undefined { + const nodes = segments.findIndex( + segment => segment === "nodes" || segment === "seeds", + ); + const index = + nodes !== -1 && segments.length > nodes + 2 + ? nodes + 2 + : segments.findIndex(segment => segment.startsWith("rad:")); + if (index === -1) return undefined; + + const repo = segments[index].replace(/^rad:/, ""); + if (!isRepoId(repo)) return undefined; + + const tail = segments.slice(index + 1); + if (tail[0] !== "remotes") return { repo, tail }; + + const namespace = tail[1]; + if (namespace === undefined || !isNodeId(namespace)) return undefined; + + return { repo, namespace, tail: tail.slice(2) }; +} + +export function parseExplorerUrl(href: string): RadReference | undefined { + const parts = explorerUrlParts(href); + if (!parts) return undefined; + const { segments } = parts; const users = segments.indexOf("users"); if (users !== -1 && users === segments.length - 2) { @@ -361,19 +435,9 @@ export function parseExplorerUrl(href: string): RadReference | undefined { return node ? { type: "did", node } : undefined; } - const index = segments.findIndex(segment => segment.startsWith("rad:")); - if (index === -1) return undefined; - - const repo = segments[index].slice(4); - if (!isRepoId(repo)) return undefined; - - let tail = segments.slice(index + 1); - let namespace: string | undefined; - if (tail[0] === "remotes") { - namespace = tail[1]; - if (namespace === undefined || !isNodeId(namespace)) return undefined; - tail = tail.slice(2); - } + const page = explorerRepoPage(segments); + if (!page) return undefined; + const { repo, namespace, tail } = page; const [kind, ...rest] = tail; if (kind === undefined || (kind === "tree" && rest.length === 0)) { @@ -382,25 +446,20 @@ export function parseExplorerUrl(href: string): RadReference | undefined { if (kind === "tree") { const [revision, ...path] = rest; - if (isOid(revision)) { - return { - type: "uri", - uri: { - repo, - namespace, - resource: { type: "commit", ref: revision.toLowerCase() }, - query: - path.length > 0 - ? [{ param: "path", value: path.join("/") }] - : undefined, - }, - }; - } - if (path.length > 0 || !gitRefPattern.test(revision)) return undefined; - return { - type: "uri", - uri: { repo, namespace, resource: { type: "commit", ref: revision } }, - }; + if (!isOid(revision)) return undefined; + return fileReference( + { repo, namespace }, + revision.toLowerCase(), + path.join("/"), + parts.fragment, + ); + } + + const typeName = Object.keys(explorerCobPaths).find( + key => explorerCobPaths[key] === kind, + ); + if (typeName && rest.length === 0) { + return { type: "uri", uri: { repo, resource: { type: "cob", typeName } } }; } const [raw, ...extra] = rest; @@ -416,9 +475,6 @@ export function parseExplorerUrl(href: string): RadReference | undefined { }; } - const typeName = Object.keys(explorerCobPaths).find( - key => explorerCobPaths[key] === kind, - ); if (!typeName) return undefined; return { @@ -426,3 +482,83 @@ export function parseExplorerUrl(href: string): RadReference | undefined { uri: { repo, resource: { type: "cob", typeName, oid } }, }; } + +export function parseExplorerTreeUrl( + href: string, +): + | { repo: string; namespace?: string; path: string; fragment?: string } + | undefined { + const parts = explorerUrlParts(href); + const page = parts && explorerRepoPage(parts.segments); + if (!page) return undefined; + + const [kind, revision, ...path] = page.tail; + if (kind !== "tree" || revision === undefined || isOid(revision)) { + return undefined; + } + + return { + repo: page.repo, + namespace: page.namespace, + path: [revision, ...path].join("/"), + fragment: parts.fragment, + }; +} + +export function cobListType(reference: RadReference): string | undefined { + if (reference.type !== "uri") return undefined; + const { resource } = reference.uri; + + return resource?.type === "cob" && resource.oid === undefined + ? resource.typeName + : undefined; +} + +export function isFileReference(reference: RadReference): boolean { + return ( + reference.type === "uri" && + (reference.uri.resource?.type === "commit" || + reference.uri.resource?.type === "tag") && + queryPath(reference.uri) !== undefined + ); +} + +export function filePath( + reference: RadReference, +): { revision: string; path: string } | undefined { + if (reference.type !== "uri" || !isFileReference(reference)) return undefined; + const { resource } = reference.uri; + const raw = queryPath(reference.uri); + if (!resource || !("ref" in resource) || raw === undefined) return undefined; + + return { revision: resource.ref, path: decodeSegment(raw) }; +} + +export function fileReference( + location: { repo: string; namespace?: string }, + revision: string, + path: string, + fragment?: string, +): RadReference | undefined { + if (!gitRefPattern.test(revision) || !isSafeRef(revision)) return undefined; + + return { + type: "uri", + uri: { + repo: location.repo, + namespace: location.namespace, + resource: { type: "commit", ref: revision }, + query: [ + { + param: "path", + value: path + .split("/") + .filter(segment => segment !== "") + .map(encodeURIComponent) + .join("/"), + }, + ], + fragment, + }, + }; +} diff --git a/src/lib/textareaCaret.ts b/src/lib/textareaCaret.ts new file mode 100644 index 000000000..3ce50cf16 --- /dev/null +++ b/src/lib/textareaCaret.ts @@ -0,0 +1,61 @@ +const mirroredProperties = [ + "boxSizing", + "borderBottomWidth", + "borderLeftWidth", + "borderRightWidth", + "borderTopWidth", + "fontFamily", + "fontSize", + "fontSizeAdjust", + "fontStretch", + "fontStyle", + "fontVariant", + "fontWeight", + "letterSpacing", + "lineHeight", + "paddingBottom", + "paddingLeft", + "paddingRight", + "paddingTop", + "tabSize", + "textIndent", + "textTransform", + "wordSpacing", +] as const; + +export function caretCoordinates( + textarea: HTMLTextAreaElement, + position: number, +): { top: number; left: number; height: number } { + const style = window.getComputedStyle(textarea); + const mirror = document.createElement("div"); + + mirror.style.position = "absolute"; + mirror.style.top = "0"; + mirror.style.left = "0"; + mirror.style.visibility = "hidden"; + mirror.style.whiteSpace = "pre-wrap"; + mirror.style.wordWrap = "break-word"; + mirror.style.overflow = "hidden"; + mirror.style.width = `${textarea.clientWidth}px`; + mirror.style.height = "auto"; + for (const property of mirroredProperties) { + mirror.style[property] = style[property]; + } + + mirror.textContent = textarea.value.slice(0, position).replace(/\n$/, "\n "); + + const marker = document.createElement("span"); + marker.textContent = "​"; + mirror.appendChild(marker); + + document.body.appendChild(mirror); + const { offsetTop, offsetLeft, offsetHeight } = marker; + document.body.removeChild(mirror); + + return { + top: offsetTop, + left: offsetLeft, + height: offsetHeight || parseFloat(style.lineHeight) || 0, + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9eb10e4eb..02517185a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -7,11 +7,13 @@ import type { Commit } from "@bindings/repo/Commit"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; import type { ComponentProps } from "svelte"; -import bs58 from "bs58"; import escape from "lodash/escape.js"; import md5 from "md5"; import twemojiModule from "twemoji"; +import type { RadReference } from "@app/lib/radUri"; +import { explorerUrl, isNodeId, isRepoId } from "@app/lib/radUri"; + import NodeId from "@app/components/NodeId.svelte"; export const unreachable = (value: never): never => { @@ -45,22 +47,10 @@ export function formatRepositoryId(id: string): string { export function parseRepositoryId( rid: string, ): { prefix: string; pubkey: string } | undefined { - const match = /^(rad:)?(z[a-zA-Z0-9]+)$/.exec(rid); - if (match) { - let hex: Uint8Array; - try { - hex = bs58.decode(match[2].substring(1)); - } catch { - return undefined; - } - if (hex.byteLength !== 20) { - return undefined; - } + const pubkey = rid.replace(/^rad:/, ""); + if (!isRepoId(pubkey)) return undefined; - return { prefix: match[1] || "rad:", pubkey: match[2] }; - } - - return undefined; + return { prefix: "rad:", pubkey }; } export function truncateId(pubkey: string): string { @@ -79,10 +69,6 @@ export function publicKeyFromDid(did: string) { return did.replace("did:key:", ""); } -export function isCommit(input: string): boolean { - return /^[a-f0-9]{40}$/.test(input); -} - export function formatOid(id: string): string { return id.substring(0, 7); } @@ -260,25 +246,10 @@ export function modifierKey() { export function parseNodeId( nid: string, ): { prefix: string; pubkey: string } | undefined { - const match = /^(did:key:)?(z[a-zA-Z0-9]+)$/.exec(nid); - if (match) { - let hex: Uint8Array; - try { - hex = bs58.decode(match[2].substring(1)); - } catch (error) { - console.error("utils.parseNodId: Not able to decode received NID", error); - return undefined; - } - // This checks also that the first 2 bytes are equal - // to the ed25519 public key type used. - if (hex && !(hex.byteLength === 34 && hex[0] === 0xed && hex[1] === 1)) { - return undefined; - } + const pubkey = nid.replace(/^did:key:/, ""); + if (!isNodeId(pubkey)) return undefined; - return { prefix: match[1] || "did:key:", pubkey: match[2] }; - } - - return undefined; + return { prefix: "did:key:", pubkey }; } // Get the gravatar URL of an email. @@ -384,15 +355,25 @@ export function explorerHost(config: Config): string { } } -// Build an explorer URL, honouring the user's configured `publicExplorer` -// template and preferred seed. The template mirrors radicle's `Explorer` -// type: "/nodes/$host/$rid$path". `path` is everything after the seed -// host, e.g. `/issues/` or `users/`. -export function explorerUrl(path: string, config: Config): string { - const seed = preferredSeedHost(config) ?? DEFAULT_SEED; - return config.publicExplorer - .replace("$host", seed) - .replace("$rid$path", path); +export function explorerLink( + reference: RadReference, + config: Config, +): string | undefined { + return explorerUrl(reference, explorerBase(config), explorerSeed(config)); +} + +function explorerBase(config: Config): string { + const nodes = config.publicExplorer.indexOf("/nodes/$host"); + if (nodes !== -1) return config.publicExplorer.slice(0, nodes); + try { + return new URL(config.publicExplorer).origin; + } catch { + return config.publicExplorer; + } +} + +function explorerSeed(config: Config): string { + return preferredSeedHost(config) ?? DEFAULT_SEED; } export interface GitIdentity { diff --git a/src/views/repo/Issue.svelte b/src/views/repo/Issue.svelte index ac79493ee..abed76e71 100644 --- a/src/views/repo/Issue.svelte +++ b/src/views/repo/Issue.svelte @@ -310,7 +310,7 @@ {/if} {/if} diff --git a/src/views/repo/RepoHome.svelte b/src/views/repo/RepoHome.svelte index cc4470874..a866b33e3 100644 --- a/src/views/repo/RepoHome.svelte +++ b/src/views/repo/RepoHome.svelte @@ -33,12 +33,22 @@ oid: string; commit: Commit; readme: Readme | null; + path?: string; sidebarData: SidebarData; } /* eslint-disable prefer-const */ - let { tree, readme, repo, peer, revision, oid, commit, sidebarData }: Props = - $props(); + let { + tree, + readme, + repo, + peer, + revision, + oid, + commit, + path, + sidebarData, + }: Props = $props(); /* eslint-enable prefer-const */ const baseRoute = $derived({ @@ -53,8 +63,12 @@ let error: InvokeError | undefined = $state(); $effect(() => { - blob = readme; - currentPath = readme?.path || ""; + if (path) { + void openPath(path); + } else { + blob = readme; + currentPath = readme?.path || ""; + } }); function isMarkdownPath(path: string): boolean { @@ -83,6 +97,19 @@ return; } + async function openPath(path: string) { + await fetchBlob(path); + if (!error) return; + const directory = await fetchTree(path).then( + () => true, + () => false, + ); + if (directory) { + blob = readme; + error = undefined; + } + } + $effect(() => { if (codeElement) { const [initialize] = useOverlayScrollbars({ diff --git a/src/views/repo/router.ts b/src/views/repo/router.ts index 8e2a09d66..d76053174 100644 --- a/src/views/repo/router.ts +++ b/src/views/repo/router.ts @@ -34,6 +34,7 @@ export interface RepoHomeRoute { rid: string; peer?: string; revision?: string; + path?: string; } export interface RepoCommitsRoute { @@ -82,6 +83,7 @@ export interface LoadedRepoHomeRoute { commit: Commit; tree: Tree; readme: Readme | null; + path?: string; sidebarData: SidebarData; }; } @@ -369,6 +371,7 @@ export async function loadRepoHome( ...context, readme, tree, + path: route.path, }, }; } @@ -510,6 +513,10 @@ export function repoRouteToPath(route: RepoRoute): string { if (route.revision !== undefined) { segments.push(route.revision); } + if (route.path) { + searchParams.set("path", route.path); + return `${segments.join("/")}?${searchParams}`; + } return segments.join("/"); } else if (route.resource === "repo.commits") { const segments = [...pathSegments, "commits"]; @@ -576,7 +583,8 @@ export function repoUrlToRoute( peer = segments.shift(); } const revision = segments.length > 0 ? segments.join("/") : undefined; - return { resource: "repo.home", rid, peer, revision }; + const path = searchParams.get("path") ?? undefined; + return { resource: "repo.home", rid, peer, revision, path }; } else if (resource === "commits") { let peer: string | undefined; if (segments[0] === "remotes") { diff --git a/tests/e2e/repo/mentions.spec.ts b/tests/e2e/repo/mentions.spec.ts new file mode 100644 index 000000000..f6cd4e6ea --- /dev/null +++ b/tests/e2e/repo/mentions.spec.ts @@ -0,0 +1,238 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import { createProject } from "@tests/support/collaboration.js"; +import { + expect, + reload, + test, + waitForCommand, +} from "@tests/support/fixtures.js"; + +async function openIssue(page: Page, peer: RadiclePeer) { + const project = await createProject(peer); + await page.goto(`/repos/${project.rid}/issues/${project.issueId}`); + await expect( + page.getByRole("button", { name: "The notes are too quiet" }), + ).toBeVisible(); + return project; +} + +function commentBox(page: Page) { + return page.getByPlaceholder("Leave a comment"); +} + +async function type(page: Page, text: string) { + await commentBox(page).click(); + await page.keyboard.insertText(text); +} + +function suggestion(page: Page, text: string) { + return page.locator(".suggestion", { hasText: text }); +} + +async function comment(page: Page) { + await waitForCommand(page, "create_issue_comment", () => + page.getByRole("button", { name: /^Comment/ }).click(), + ); +} + +function rendered(page: Page) { + return page.locator(".markdown"); +} + +test("mentioning yourself inserts your DID", async ({ page, peer }) => { + await openIssue(page, peer); + + await type(page, "@ali"); + await expect(suggestion(page, "alice").first()).toContainText("you"); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue( + `[@alice](did:key:${peer.nodeId}) `, + ); + + await comment(page); + await reload(page); + await expect( + rendered(page).locator(".mention-node", { hasText: "alice" }), + ).toBeVisible(); +}); + +test("a pasted patch URI becomes a chip that opens the patch", async ({ + page, + peer, +}) => { + const { rid, patchId } = await openIssue(page, peer); + const uri = `${rid}/cob/xyz.radicle.patch/${patchId}`; + + await type(page, uri); + await expect(suggestion(page, "Shout the third line")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue(`[Shout the third line](${uri}) `); + + await comment(page); + await reload(page); + const chip = rendered(page).locator("a.mention", { + hasText: "Shout the third line", + }); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page).toHaveURL(new RegExp(`/patches/${patchId}`)); +}); + +test("unsafe and invalid references stay inert", async ({ page, peer }) => { + const { rid } = await openIssue(page, peer); + + await commentBox(page).fill( + `[click me](javascript:alert(1)) and ${rid}/bogus here`, + ); + await comment(page); + await reload(page); + + const link = rendered(page).locator("a", { hasText: "click me" }); + await expect(link).toBeVisible(); + expect(await link.getAttribute("href")).toBeNull(); + await expect(rendered(page).getByText(`${rid}/bogus here`)).toBeVisible(); + await expect(rendered(page).locator("a.mention")).toHaveCount(0); +}); + +test("a typed commit prefix expands to the commit", async ({ page, peer }) => { + const { rid, repoFolder } = await openIssue(page, peer); + const { stdout: head } = await peer.git(["rev-parse", "main"], { + cwd: repoFolder, + }); + + await type(page, `#${head.slice(0, 7)}`); + await expect(suggestion(page, "Add notes")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue( + `[Add notes](${rid}/commit/${head}) `, + ); + + await comment(page); + await reload(page); + await rendered(page).locator("a.mention", { hasText: "Add notes" }).click(); + await expect(page).toHaveURL(new RegExp(`/commits/${head}`)); +}); + +test("a pasted explorer file URL opens the file in-app", async ({ + page, + peer, +}) => { + const { rid } = await openIssue(page, peer); + const url = `https://radicle.network/nodes/seed.radicle.xyz/${rid}/tree/main/notes.txt#L3`; + const uri = `${rid}/commit/main?path=notes.txt#L3`; + + await type(page, url); + await expect(suggestion(page, "notes.txt")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue( + `[project: notes.txt#L3](${uri}) `, + ); + + await comment(page); + await reload(page); + const link = rendered(page).locator("a", { + hasText: "project: notes.txt#L3", + }); + await link.click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Open in radicle.network" }), + ).toHaveAttribute( + "href", + `https://radicle.network/nodes/rosa.radicle.network/${rid}/tree/main/notes.txt#L3`, + ); + await expect( + page.getByRole("menuitem", { name: "Copy rad: URI" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await link.click(); + await expect(page).toHaveURL(/\/home\/main\?path=notes\.txt/); + await expect(page.locator("code", { hasText: "three" })).toBeVisible(); +}); + +test("every reference type renders and links where it should", async ({ + page, + peer, +}) => { + const { rid, issueId, patchId, repoFolder } = await openIssue(page, peer); + const { stdout: head } = await peer.git(["rev-parse", "main"], { + cwd: repoFolder, + }); + const nid = peer.nodeId; + const missingRid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"; + const missingOid = "f".repeat(40); + const explorer = `https://radicle.network/nodes/rosa.radicle.network`; + const repo = `/repos/${rid}`; + + const references: [string, string][] = [ + ["repo", rid], + ["issue", `${rid}/cob/xyz.radicle.issue/${issueId}`], + ["patch", `${rid}/cob/xyz.radicle.patch/${patchId}`], + ["commit", `${rid}/commit/${head}`], + ["person", `did:key:${nid}`], + ["file", `${rid}/commit/main?path=notes.txt#L2`], + ["root", `${rid}/commit/main?path=`], + ["branch", `${rid}/commit/main`], + ["tag", `${rid}/tag/v1.0`], + ["remote", `${rid}/${nid}`], + ["issues", `${rid}/cob/xyz.radicle.issue`], + ["patches", `${rid}/cob/xyz.radicle.patch`], + ["release", `${rid}/cob/dev.radicle.artifact/${missingOid}`], + ["releases", `${rid}/cob/dev.radicle.artifact`], + ["tree", `${rid}/tree/${missingOid}`], + ["missing repo", missingRid], + ["missing issue", `${rid}/cob/xyz.radicle.issue/${missingOid}`], + ]; + await commentBox(page).fill( + references.map(([label, uri]) => `- [${label}](${uri})`).join("\n"), + ); + await comment(page); + await reload(page); + const body = rendered(page).last(); + const link = (href: string) => body.locator(`a[href="${href}"]`); + + await expect(link(`${repo}/home`).first()).toHaveClass(/mention/); + await expect(link(`${repo}/issues/${issueId}?status=all`)).toContainText( + "The notes are too quiet", + ); + await expect(link(`${repo}/patches/${patchId}`)).toContainText( + "Shout the third line", + ); + await expect(link(`${repo}/commits/${head}`)).toContainText("Add notes"); + await expect(body.locator(".mention-node")).toContainText("alice"); + + await expect(link(`${repo}/home/main?path=notes.txt`)).toHaveText("file"); + await expect(link(`${repo}/home/main`)).toHaveText(["root", "branch"]); + await expect(link(`${repo}/home/v1.0`)).toHaveText("tag"); + await expect(link(`${repo}/home/remotes/${nid}`)).toHaveText("remote"); + await expect(link(`${repo}/issues?status=all`)).toHaveText("issues"); + await expect(link(`${repo}/patches`)).toHaveText("patches"); + await expect( + link(`${explorer}/${rid}/releases/${missingOid}`), + ).toHaveAttribute("target", "_blank"); + await expect(link(`${explorer}/${rid}/releases`)).toHaveText("releases"); + await expect( + body.locator("a:not([href])", { hasText: "tree" }), + ).toBeVisible(); + + const missingRepo = link(`${explorer}/${missingRid}`); + await expect(missingRepo).toHaveClass(/unresolved/); + await expect(missingRepo).toContainText("z3gqcJ"); + const missingIssue = link(`${explorer}/${rid}/issues/${missingOid}`); + await expect(missingIssue).toHaveClass(/unresolved/); + await expect(missingIssue).toContainText("missing issue"); + await expect(missingIssue).toContainText("fffffff"); + + await link(`${repo}/patches/${patchId}`).click({ button: "right" }); + await expect(page.getByRole("menuitem")).toHaveText([ + "Open in radicle.network", + "Copy link to radicle.network", + "Copy rad: URI", + ]); + await page.keyboard.press("Escape"); + + await link(`${repo}/issues?status=all`).click(); + await expect(page).toHaveURL(/\/issues\?status=all$/); +}); diff --git a/tests/unit/cached.test.ts b/tests/unit/cached.test.ts index dcde1a2c9..48be42acf 100644 --- a/tests/unit/cached.test.ts +++ b/tests/unit/cached.test.ts @@ -32,6 +32,28 @@ describe("cached", () => { expect(f).toHaveBeenCalledTimes(1); }); + test("keeps a miss only for missTtl", async () => { + const f = vi.fn(async () => null); + const c = cached(f, () => "key", { max: 10, ttl: 60_000 }, 1); + + await c(); + await c(); + expect(f).toHaveBeenCalledTimes(1); + await new Promise(resolve => setTimeout(resolve, 10)); + await c(); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("keeps a hit for the full ttl", async () => { + const f = vi.fn(async () => "hit"); + const c = cached(f, () => "key", { max: 10, ttl: 60_000 }, 1); + + await c(); + await new Promise(resolve => setTimeout(resolve, 10)); + await c(); + expect(f).toHaveBeenCalledTimes(1); + }); + test("retries after a rejection", async () => { const f = vi .fn<() => Promise>() diff --git a/tests/unit/mentionTrigger.test.ts b/tests/unit/mentionTrigger.test.ts new file mode 100644 index 000000000..3f67db423 --- /dev/null +++ b/tests/unit/mentionTrigger.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "vitest"; + +import { findMentionTrigger } from "@app/lib/mentionTrigger"; + +const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const rid = `rad:${repo}`; +const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; +const oid = "5e4683023476778201ab25c87e1343fbfbe67c89"; + +function trigger(text: string, caret = text.length) { + return findMentionTrigger(text, caret); +} + +describe("queries", () => { + test.each([ + ["@", { scope: "all", start: 0, query: "" }], + ["#", { scope: "cobs", start: 0, query: "" }], + ["hi @rud", { scope: "all", start: 3, query: "rud" }], + ["(#fix", { scope: "cobs", start: 1, query: "fix" }], + ["line\n@a.b-c", { scope: "all", start: 5, query: "a.b-c" }], + ])("%j", (text, expected) => { + expect(trigger(text)).toEqual({ + kind: "query", + end: text.length, + ...expected, + }); + }); + + test.each([ + ["an email address", "me@example"], + ["a URL fragment", "page#section"], + ["a heading", "# Heading"], + ["a query past the limit", `@${"a".repeat(65)}`], + ])("ignores %s", (_, text) => { + expect(trigger(text)).toBeUndefined(); + }); + + test("only considers text up to the caret", () => { + expect(trigger("@rud and more", 4)).toMatchObject({ query: "rud" }); + expect(trigger("@rud and more")).toBeUndefined(); + }); +}); + +describe("identifiers", () => { + test.each([ + [`see did:key:${nid}`, 4, { type: "node", nid }], + [`(${rid}`, 1, { type: "repo", rid }], + [ + `https://app.radicle.xyz/nodes/seed/${rid}/issues/${oid}`, + 0, + { type: "cob", kind: "issue", rid, oid }, + ], + [`https://app.radicle.xyz/nodes/seed/${rid}`, 0, { type: "repo", rid }], + [nid, 0, { type: "node", nid }], + [`x ${repo}`, 2, { type: "repo", rid }], + ])("%s", (text, start, target) => { + expect(trigger(text)).toEqual({ + kind: "identifier", + start, + end: text.length, + target, + }); + }); + + test("does not reopen on an inserted link", () => { + expect(trigger(`[heartwood](${rid})`)).toBeUndefined(); + }); + + test("offers a full oid, lowercased", () => { + expect(trigger(`see ${oid.toUpperCase()}`)).toEqual({ + kind: "oid", + start: 4, + end: 44, + oid, + }); + }); + + test("ignores an abbreviated oid", () => { + expect(trigger(oid.slice(0, 39))).toBeUndefined(); + }); +}); + +describe("explorer file links", () => { + test("defers the branch and path split to the repo", () => { + const url = `https://radicle.network/nodes/seed/${rid}/tree/0004-general-uri-scheme/general-uri-scheme.adoc`; + expect(trigger(`see ${url}`)).toEqual({ + kind: "tree", + start: 4, + end: 4 + url.length, + rid, + namespace: undefined, + path: "0004-general-uri-scheme/general-uri-scheme.adoc", + }); + }); +}); + +describe("rad: file links", () => { + test("offers a file URI as a file", () => { + const uri = `${rid}/commit/master?path=.github/README.md`; + expect(trigger(`see ${uri}`)).toMatchObject({ + kind: "link", + start: 4, + end: 4 + uri.length, + }); + }); + + test.each([ + ["a tag", `${rid}/tag/v1.0`], + ["a branch", `${rid}/commit/main`], + ["a remote", `${rid}/${nid}`], + [ + "a remote's explorer page", + `https://radicle.network/nodes/seed/${rid}/remotes/${nid}`, + ], + ])("offers %s as a link", (_, text) => { + expect(trigger(text)).toMatchObject({ kind: "link", start: 0 }); + }); + + test.each([ + ["a tree by oid", `${rid}/tree/${oid}`], + ["a blob by oid", `${rid}/blob/${oid}`], + ["an unknown cob type", `${rid}/cob/org.example/${oid}`], + ])("offers nothing for %s, which has no page", (_, text) => { + expect(trigger(text)).toBeUndefined(); + }); + + test("does not take the tail of a path as a bare id", () => { + expect(trigger(`/some/path/${nid}`)).toBeUndefined(); + expect(trigger(`see:${oid}`)).toBeUndefined(); + }); +}); + +describe("explorer links without a chip", () => { + const node = "https://radicle.network/nodes/seed"; + + test.each([ + ["an issue list", `${node}/${rid}/issues`], + ["a path at an oid", `${node}/${rid}/tree/${oid}/src`], + ["a tree at an oid", `${node}/${rid}/tree/${oid}`], + ])("offers %s as a link", (_, url) => { + expect(trigger(url)).toMatchObject({ kind: "link", start: 0 }); + }); + + test("leaves a lone branch for the repo to resolve", () => { + expect(trigger(`${node}/${rid}/tree/main`)).toMatchObject({ + kind: "tree", + path: "main", + }); + }); +}); + +describe("releases and lines", () => { + const node = "https://radicle.network/nodes/seed"; + + test("offers a release as a link", () => { + expect(trigger(`${node}/${rid}/releases/${oid}`)).toMatchObject({ + kind: "link", + reference: { + uri: { + resource: { type: "cob", typeName: "dev.radicle.artifact", oid }, + }, + }, + }); + }); + + test("keeps the line of a file on a branch", () => { + expect(trigger(`${node}/${rid}/tree/main/src/lib.rs#L10`)).toMatchObject({ + kind: "tree", + path: "main/src/lib.rs", + fragment: "L10", + }); + }); + + test("offers a rad: file URI with a line as a link", () => { + expect(trigger(`${rid}/commit/main?path=src/lib.rs#L10`)).toMatchObject({ + kind: "link", + reference: { uri: { fragment: "L10" } }, + }); + }); +}); diff --git a/tests/unit/mentions.test.ts b/tests/unit/mentions.test.ts new file mode 100644 index 000000000..058703ae1 --- /dev/null +++ b/tests/unit/mentions.test.ts @@ -0,0 +1,324 @@ +import type { Config } from "@bindings/config/Config"; + +import { describe, expect, test } from "vitest"; + +import { markdownWithExtensions } from "@app/lib/markdown"; +import { + bareReferenceStart, + describeLink, + entityMarkdown, + entityUri, + entityUrl, + matchBareReference, + parseBareIdentifier, + parseEntityHref, + referenceMarkdown, + referenceRoute, +} from "@app/lib/mentions"; +import { parseReference } from "@app/lib/radUri"; + +const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const rid = `rad:${repo}`; +const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; +const oid = "5e4683023476778201ab25c87e1343fbfbe67c89"; + +describe("parseEntityHref", () => { + test.each([ + [`did:key:${nid}`, { type: "node", nid }], + [rid, { type: "repo", rid }], + [`${rid}/commit/${oid}`, { type: "commit", rid, oid }], + [ + `${rid}/cob/xyz.radicle.issue/${oid}`, + { type: "cob", kind: "issue", rid, oid }, + ], + [ + `${rid}/cob/xyz.radicle.patch/${oid}`, + { type: "cob", kind: "patch", rid, oid }, + ], + [ + `https://app.radicle.xyz/nodes/seed/${rid}/patches/${oid}`, + { type: "cob", kind: "patch", rid, oid }, + ], + [` ${rid} `, { type: "repo", rid }], + ])("%s", (href, expected) => { + expect(parseEntityHref(href)).toEqual(expected); + }); + + test.each([ + ["a tag", `${rid}/tag/v1.0`], + ["a tree", `${rid}/tree/${oid}`], + ["a commit by branch", `${rid}/commit/master`], + ["an unknown cob type", `${rid}/cob/org.example/${oid}`], + ["a cob list", `${rid}/cob/xyz.radicle.issue`], + ["a namespace", `${rid}/${nid}`], + ["a query", `${rid}/commit/${oid}?path=src`], + ["a fragment", `${rid}/commit/${oid}#L10`], + ["an unrelated link", "https://example.com"], + ])("has no chip for %s", (_, href) => { + expect(parseEntityHref(href)).toBeUndefined(); + }); +}); + +describe("parseBareIdentifier", () => { + test.each([ + [nid, { type: "node", nid }], + [repo, { type: "repo", rid }], + ["zlib", undefined], + [oid, undefined], + ])("%s", (token, expected) => { + expect(parseBareIdentifier(token)).toEqual(expected); + }); +}); + +describe("bareReferenceStart", () => { + test.each([ + [`${rid} at the start`, 0], + [`see ${rid}`, 4], + [`(did:key:${nid})`, 1], + [`Conrad:${repo}`, undefined], + [`Conrad:${repo} and ${rid}`, 41], + ])("%s", (src, expected) => { + expect(bareReferenceStart(src)).toBe(expected); + }); +}); + +describe("matchBareReference", () => { + test.each([ + [`${rid}.`, rid], + [`${rid}).`, rid], + [`${rid}'s`, rid], + [`${rid}, then`, rid], + [`did:key:${nid}!`, `did:key:${nid}`], + [`${rid}/commit/master?path=src.`, `${rid}/commit/master?path=src`], + ])("%s", (src, raw) => { + expect(matchBareReference(src)?.raw).toBe(raw); + }); + + test("gives up on a run too long to be a reference", () => { + const start = performance.now(); + expect(matchBareReference(`rad:z${"'a".repeat(50_000)}`)).toBeUndefined(); + expect(performance.now() - start).toBeLessThan(50); + }); + + test("leaves an invalid reference as prose", () => { + expect(matchBareReference(`${rid}/bogus here`)).toBeUndefined(); + }); +}); + +describe("markdown", () => { + const render = (src: string) => markdownWithExtensions.parse(src) as string; + + test("links a bare reference", () => { + expect(render(`See ${rid}.`)).toBe( + `

See ${rid}.

\n`, + ); + }); + + test("leaves the tail of a word alone", () => { + expect(render(`Conrad:${repo}`)).not.toContain(" { + expect(render(`[see ${rid}](https://example.com)`)).toBe( + `

see ${rid}

\n`, + ); + }); + + test("leaves code alone", () => { + expect(render(`\`${rid}\``)).toBe(`

${rid}

\n`); + }); +}); + +describe("serialising", () => { + const issue = { type: "cob", kind: "issue", rid, oid } as const; + + test("entityUri writes the RIP 4 form", () => { + expect(entityUri(issue)).toBe(`${rid}/cob/xyz.radicle.issue/${oid}`); + expect(entityUri({ type: "node", nid })).toBe(`did:key:${nid}`); + expect(entityUri({ ...issue, kind: "patch" })).toBe( + `${rid}/cob/xyz.radicle.patch/${oid}`, + ); + expect(entityUri({ type: "commit", rid, oid })).toBe( + `${rid}/commit/${oid}`, + ); + }); + + test("referenceMarkdown links any reference", () => { + expect( + referenceMarkdown( + { + type: "uri", + uri: { repo, resource: { type: "commit", ref: "main" } }, + }, + "heartwood: README", + ), + ).toBe(`[heartwood: README](${rid}/commit/main)`); + }); + + test("entityMarkdown escapes the label", () => { + expect(entityMarkdown(issue, "Fix [x] \\ y")).toBe( + `[Fix \\[x\\] \\\\ y](${rid}/cob/xyz.radicle.issue/${oid})`, + ); + }); + + test("entityUrl uses the configured explorer and preferred seed", () => { + const config = { + publicExplorer: "https://explorer.example/nodes/$host/$rid$path", + preferredSeeds: [`${nid}@seed.example.com:8776`], + } as Config; + + expect(entityUrl(issue, config)).toBe( + `https://explorer.example/nodes/seed.example.com/${rid}/issues/${oid}`, + ); + expect(entityUrl({ type: "node", nid }, config)).toBe( + `https://explorer.example/nodes/seed.example.com/users/did:key:${nid}`, + ); + }); + + test("entityUrl keeps a path prefix in the explorer URL", () => { + const config = { + publicExplorer: "https://example.com/explorer/nodes/$host/$rid$path", + preferredSeeds: [], + } as unknown as Config; + + expect(entityUrl({ type: "repo", rid }, config)).toBe( + `https://example.com/explorer/nodes/rosa.radicle.network/${rid}`, + ); + }); +}); + +describe("referenceRoute", () => { + test.each([ + [ + `${rid}/commit/master?path=.github/README.md`, + { + resource: "repo.home", + rid, + revision: "master", + path: ".github/README.md", + }, + ], + [ + `${rid}/commit/main?path=My%20File.md`, + { resource: "repo.home", rid, revision: "main", path: "My File.md" }, + ], + [ + `${rid}/commit/refs/heads/main?path=`, + { resource: "repo.home", rid, revision: "main" }, + ], + [ + `${rid}/${nid}/commit/main?path=src`, + { resource: "repo.home", rid, peer: nid, revision: "main", path: "src" }, + ], + [`${rid}/tag/v1.0`, { resource: "repo.home", rid, revision: "v1.0" }], + [`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }], + [ + `${rid}/cob/xyz.radicle.issue/${oid}`, + { resource: "repo.issue", rid, issue: oid, status: "all" }, + ], + [ + `${rid}/cob/xyz.radicle.patch/${oid}`, + { + resource: "repo.patch", + rid, + patch: oid, + status: undefined, + reviewId: undefined, + }, + ], + [`${rid}/${nid}`, { resource: "repo.home", rid, peer: nid }], + [ + `${rid}/cob/xyz.radicle.issue`, + { resource: "repo.issues", rid, status: "all" }, + ], + [ + `${rid}/cob/xyz.radicle.patch`, + { resource: "repo.patches", rid, status: undefined }, + ], + ])("%s", (href, expected) => { + expect(referenceRoute(parseReference(href)!)).toEqual(expected); + }); + + test.each([ + `${rid}/cob/dev.radicle.artifact`, + `${rid}/tree/${oid}`, + `did:key:${nid}`, + ])("has no page for %s", href => { + expect(referenceRoute(parseReference(href)!)).toBeUndefined(); + }); +}); + +describe("describeLink", () => { + test.each([ + [`${rid}/commit/main?path=src/lib.rs`, "heartwood: src/lib.rs", "document"], + [ + `${rid}/commit/main?path=src/lib.rs#L10`, + "heartwood: src/lib.rs#L10", + "document", + ], + [`${rid}/commit/main?path=`, "heartwood: main", "folder"], + [`${rid}/commit/${oid}?path=`, "heartwood: 5e46830", "folder"], + [`${rid}/commit/refs/heads/main`, "heartwood: main", "branch"], + [`${rid}/tag/v1.0`, "heartwood: v1.0", "label"], + [`${rid}/cob/xyz.radicle.issue`, "heartwood: issues", "issue"], + [`${rid}/cob/xyz.radicle.patch`, "heartwood: patches", "patch"], + [`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "archive"], + [ + `${rid}/cob/dev.radicle.artifact/${oid}`, + "heartwood: release 5e46830", + "archive", + ], + [`${rid}/${nid}`, "heartwood: z6MknS…tBU8Vi", "repository"], + ])("%s", (href, label, icon) => { + expect(describeLink(parseReference(href)!, "heartwood")).toMatchObject({ + label, + icon, + }); + }); + + test("names a remote by its alias", () => { + expect( + describeLink(parseReference(`${rid}/${nid}`)!, "heartwood", "cloudhead"), + ).toMatchObject({ label: "heartwood: cloudhead" }); + }); + + test.each([ + rid, + `${rid}/commit/${oid}`, + `${rid}/cob/xyz.radicle.issue/${oid}`, + `${rid}/tree/${oid}`, + `${rid}/${nid}/cob/xyz.radicle.issue/${oid}`, + `did:key:${nid}`, + ])("is not a link for %s", href => { + expect(describeLink(parseReference(href)!, "heartwood")).toBeUndefined(); + }); +}); + +describe("hostile input", () => { + test("renders a run of references in linear time", () => { + const start = performance.now(); + void markdownWithExtensions.parse("rad:".repeat(25_000)); + expect(performance.now() - start).toBeLessThan(500); + }); + + test("links at most a fixed number of references per document", () => { + const start = performance.now(); + const html = markdownWithExtensions.parse( + `${rid} `.repeat(5_000), + ) as string; + expect(performance.now() - start).toBeLessThan(1_000); + expect(html.match(/ { + const reference = parseReference(href)!; + expect(referenceRoute(reference)).toBeUndefined(); + expect(describeLink(reference, "heartwood")).toBeUndefined(); + }); +}); diff --git a/tests/unit/radUri.test.ts b/tests/unit/radUri.test.ts index c1863cc6c..8305e34df 100644 --- a/tests/unit/radUri.test.ts +++ b/tests/unit/radUri.test.ts @@ -2,9 +2,15 @@ import { describe, expect, test } from "vitest"; import type { RadUri } from "@app/lib/radUri"; import { + cobListType, explorerUrl, + filePath, + fileReference, formatRadUri, + isFileReference, + isSafeRef, issueType, + parseExplorerTreeUrl, parseExplorerUrl, parseRadUri, parseReference, @@ -419,7 +425,6 @@ describe("explorer URL edge cases", () => { ["users before the end", `${node}/users/did:key:${node01}/x`], ["a branch followed by a path", `${node}/rad:${repo00}/tree/feature/x`], ["a history page", `${node}/rad:${repo00}/history/main`], - ["an issue with extra segments", `${node}/rad:${repo00}/issues/${oid}/x`], [ "an invalid remote", `${node}/rad:${repo00}/remotes/z6Mk11111111111111111111111111111111111111111111`, @@ -454,19 +459,43 @@ describe("explorer URL edge cases", () => { { repo: repo00, namespace: node01 }, ], [ - "a branch", - `${node}/rad:${repo00}/tree/main`, - { repo: repo00, resource: { type: "commit", ref: "main" } }, - ], - [ - "a remote's branch", - `${node}/rad:${repo00}/remotes/${node01}/tree/main`, + "a tree at an oid", + `${node}/rad:${repo00}/tree/${oid}`, { repo: repo00, - namespace: node01, - resource: { type: "commit", ref: "main" }, + resource: { type: "commit", ref: oid }, + query: [{ param: "path", value: "" }], }, ], + [ + "an encoded path at an oid", + `${node}/rad:${repo00}/tree/${oid}/My%20File.md`, + { + repo: repo00, + resource: { type: "commit", ref: oid }, + query: [{ param: "path", value: "My%20File.md" }], + }, + ], + [ + "an issue list", + `${node}/rad:${repo00}/issues?status=open`, + { repo: repo00, resource: { type: "cob", typeName: issueType } }, + ], + [ + "a patch list", + `${node}/rad:${repo00}/patches`, + { repo: repo00, resource: { type: "cob", typeName: patchType } }, + ], + [ + "a release list", + `${node}/rad:${repo00}/releases`, + { repo: repo00, resource: { type: "cob", typeName: releaseType } }, + ], + [ + "a bare repo id", + `${node}/${repo00}/issues/${oid}`, + { repo: repo00, resource: { type: "cob", typeName: issueType, oid } }, + ], [ "a path at a commit", `${node}/rad:${repo00}/tree/${oid}/src/lib`, @@ -487,3 +516,194 @@ describe("explorer URL edge cases", () => { ); }); }); + +describe("file links", () => { + const base = "https://radicle.network"; + const node = `${base}/nodes/seed.example.com`; + + test.each([ + [ + `${node}/rad:${repo00}/tree/0004-general-uri-scheme/general-uri-scheme.adoc`, + { repo: repo00, path: "0004-general-uri-scheme/general-uri-scheme.adoc" }, + ], + [ + `${node}/rad:${repo00}/remotes/${node01}/tree/feature/x/src`, + { repo: repo00, namespace: node01, path: "feature/x/src" }, + ], + [ + `${node}/rad:${repo00}/tree/main/My%20File.md`, + { repo: repo00, path: "main/My File.md" }, + ], + ])("parseExplorerTreeUrl reads %s", (url, expected) => { + expect(parseExplorerTreeUrl(url)).toEqual(expected); + }); + + test.each([ + [ + "a branch", + `${node}/rad:${repo00}/tree/main`, + { repo: repo00, path: "main" }, + ], + [ + "a remote's branch", + `${node}/rad:${repo00}/remotes/${node01}/tree/main`, + { repo: repo00, namespace: node01, path: "main" }, + ], + ])("parseExplorerTreeUrl reads %s on its own", (_, url, expected) => { + expect(parseExplorerTreeUrl(url)).toEqual(expected); + }); + + test.each([ + ["a tree at an oid", `${node}/rad:${repo00}/tree/${oid}/src`], + ["another page", `${node}/rad:${repo00}/history/main/src`], + ])("parseExplorerTreeUrl leaves %s to parseExplorerUrl", (_, url) => { + expect(parseExplorerTreeUrl(url)).toBeUndefined(); + }); + + test("fileReference writes a path query that maps back to the file", () => { + const reference = fileReference( + { repo: repo00 }, + "main", + "docs/My File.md", + )!; + expect( + formatRadUri(reference.type === "uri" ? reference.uri : { repo: "" }), + ).toBe(`rad:${repo00}/commit/main?path=docs/My%20File.md`); + expect(explorerUrl(reference, base, "seed.example.com")).toBe( + `${node}/rad:${repo00}/tree/main/docs/My%20File.md`, + ); + }); + + test("fileReference keeps the remote", () => { + expect( + explorerUrl( + fileReference({ repo: repo00, namespace: node01 }, "main", "src")!, + base, + "seed.example.com", + ), + ).toBe(`${node}/rad:${repo00}/remotes/${node01}/tree/main/src`); + }); + + test.each([ + [`rad:${repo00}/commit/main?path=src`, true], + [`rad:${repo00}/tag/v1.0?blob=README.md`, true], + [`rad:${repo00}/commit/main`, false], + [`rad:${repo00}/commit/main?path=`, true], + [`rad:${repo00}?path=src`, false], + [`did:key:${node01}`, false], + ])("isFileReference(%s) is %s", (input, expected) => { + expect(isFileReference(parseReference(input)!)).toBe(expected); + }); + + test("filePath decodes the path", () => { + expect( + filePath( + parseReference(`rad:${repo00}/commit/main?path=docs/My%20File.md`)!, + ), + ).toEqual({ revision: "main", path: "docs/My File.md" }); + expect( + filePath(parseReference(`rad:${repo00}/commit/main`)!), + ).toBeUndefined(); + }); + + test("fileReference rejects a revision a URI cannot hold", () => { + expect(fileReference({ repo: repo00 }, "a b", "src")).toBeUndefined(); + }); +}); + +describe("tree roots and lists", () => { + const base = "https://radicle.network"; + const node = `${base}/nodes/seed`; + + test.each([ + `${node}/rad:${repo00}/tree/${oid}`, + `${node}/rad:${repo00}/issues`, + `${node}/rad:${repo00}/patches`, + `${node}/rad:${repo00}/releases`, + ])("maps %s both ways", url => { + expect(explorerUrl(parseExplorerUrl(url)!, base, "seed")).toBe(url); + }); + + test("fileReference with an empty path names the root", () => { + expect( + explorerUrl(fileReference({ repo: repo00 }, "main", "")!, base, "seed"), + ).toBe(`${node}/rad:${repo00}/tree/main`); + }); + + test.each([ + [`rad:${repo00}/cob/${issueType}`, issueType], + [`rad:${repo00}/cob/${issueType}/${oid}`, undefined], + [`rad:${repo00}`, undefined], + ])("cobListType(%s) is %s", (input, expected) => { + expect(cobListType(parseReference(input)!)).toBe(expected); + }); +}); + +describe("line fragments", () => { + const base = "https://radicle.network"; + const node = `${base}/nodes/seed`; + + test("a line of a file maps both ways", () => { + const url = `${node}/rad:${repo00}/tree/${oid}/src/lib.rs#L10`; + const reference = parseExplorerUrl(url)!; + expect(reference).toMatchObject({ uri: { fragment: "L10" } }); + expect(explorerUrl(reference, base, "seed")).toBe(url); + }); + + test("parseExplorerTreeUrl keeps the line", () => { + expect( + parseExplorerTreeUrl(`${node}/rad:${repo00}/tree/main/src/lib.rs#L10`), + ).toEqual({ repo: repo00, path: "main/src/lib.rs", fragment: "L10" }); + }); + + test("drops a fragment a URI cannot hold", () => { + expect( + parseExplorerUrl(`${node}/rad:${repo00}/tree/${oid}/src#a{b}`), + ).toMatchObject({ uri: { fragment: undefined } }); + }); + + test("only files carry a fragment onto the explorer", () => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/commit/${oid}#L10`)!, + base, + "seed", + ), + ).toBe(`${node}/rad:${repo00}/commits/${oid}`); + }); +}); + +describe("explorer paths stay on the page they name", () => { + const base = "https://radicle.network"; + const link = (input: string) => + explorerUrl(parseReference(input)!, base, "seed"); + + test.each([ + `rad:${repo00}/commit/../..`, + `rad:${repo00}/commit/main?path=../../users`, + `rad:${repo00}/commit/main?path=src/./lib`, + ])("has no page for %s", input => { + expect(link(input)).toBeUndefined(); + }); + + test("encodes characters a path segment cannot hold", () => { + expect(link(`rad:${repo00}/commit/main?path=a?b`)).toBe( + `${base}/nodes/seed/rad:${repo00}/tree/main/a%3Fb`, + ); + }); +}); + +test.each([ + ["main", true], + ["feature/x", true], + ["v1.0", true], + [oid, true], + ["..", false], + ["a/../b", false], + ["a..b", false], + [".hidden", false], + ["main.lock", false], + ["v1.", false], +])("isSafeRef(%j) is %j", (ref, expected) => { + expect(isSafeRef(ref)).toBe(expected); +}); diff --git a/tests/unit/repoRouter.test.ts b/tests/unit/repoRouter.test.ts index f964e589b..5806e1720 100644 --- a/tests/unit/repoRouter.test.ts +++ b/tests/unit/repoRouter.test.ts @@ -26,6 +26,10 @@ describe("repoRouteToPath and repoUrlToRoute round trip", () => { "/repos/RID/home/remotes/PEER/feature/branch", { resource: "repo.home", rid, peer, revision: "feature/branch" }, ], + [ + "/repos/RID/home/main?path=src%2Flib.rs", + { resource: "repo.home", rid, revision: "main", path: "src/lib.rs" }, + ], ["/repos/RID/commits", { resource: "repo.commits", rid }], [ "/repos/RID/commits/main", diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index bbc1525fb..926dfbbb1 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -6,12 +6,11 @@ import { coAuthors, creditedCoAuthors, explorerHost, - explorerUrl, + explorerLink, formatGitIdentity, formatRepositoryId, formatTimestamp, identityKey, - isCommit, isPublishableReview, parseNodeId, parseRepositoryId, @@ -162,15 +161,6 @@ test("truncateDid", () => { expect(truncateDid(`did:key:${nid}`)).toBe("did:key:z6MkqG…WTvLSK"); }); -test.each([ - ["a".repeat(40), true], - ["A".repeat(40), false], - ["a".repeat(39), false], - ["g".repeat(40), false], -])("isCommit(%j) is %j", (input, expected) => { - expect(isCommit(input)).toBe(expected); -}); - test.each([ ["refs/heads/main", "main"], ["refs/heads/feature/x", "feature/x"], @@ -246,10 +236,12 @@ describe("explorer links", () => { } as Config; } + const repo = { type: "uri", uri: { repo: rid.slice(4) } } as const; + test("uses the first preferred seed host", () => { expect( - explorerUrl( - `${rid}/issues/abc`, + explorerLink( + repo, config({ preferredSeeds: [ `${nid}@seed.example.com:8776`, @@ -257,11 +249,11 @@ describe("explorer links", () => { ], }), ), - ).toBe(`https://radicle.network/nodes/seed.example.com/${rid}/issues/abc`); + ).toBe(`https://radicle.network/nodes/seed.example.com/${rid}`); }); test("falls back to the default seed", () => { - expect(explorerUrl(rid, config({}))).toBe( + expect(explorerLink(repo, config({}))).toBe( `https://radicle.network/nodes/rosa.radicle.network/${rid}`, ); });