From b2610693075410fb70f964d7be66c5b97077b754 Mon Sep 17 00:00:00 2001 From: kyinhub Date: Sat, 25 Jul 2026 20:24:43 -0700 Subject: [PATCH 1/2] feat(desktop): open repository files from message links Signed-off-by: kyinhub --- desktop/src-tauri/src/commands/mod.rs | 1 + desktop/src-tauri/src/commands/project_git.rs | 30 ++- .../src/commands/project_git_exec.rs | 10 +- .../src/commands/project_git_focus.rs | 150 +++++++++++++++ .../src/app/navigation/useAppNavigation.ts | 4 + .../src/app/routes/projects.$projectId.tsx | 16 +- desktop/src/features/projects/hooks.ts | 29 +-- .../projects/lib/projectDetailSearch.test.mjs | 45 +++++ .../projects/lib/projectDetailSearch.ts | 37 ++++ .../lib/projectRepoSnapshotTarget.test.mjs | 85 ++++++++ .../projects/lib/projectRepoSnapshotTarget.ts | 84 ++++++++ .../projects/lib/repoFileLink.test.mjs | 162 ++++++++++++++++ .../src/features/projects/lib/repoFileLink.ts | 181 ++++++++++++++++++ .../lib/repositoryDeepLinkTarget.test.mjs | 105 ++++++++++ .../projects/lib/repositoryDeepLinkTarget.ts | 60 ++++++ .../projects/ui/ProjectDetailScreen.tsx | 71 +++---- .../projects/ui/ProjectRepositoryPanel.tsx | 140 +++++++++++--- .../projects/ui/ProjectWorkspaceTabs.tsx | 18 +- .../projects/ui/projectDetailHelpers.test.mjs | 14 ++ .../projects/ui/projectDetailHelpers.ts | 7 + desktop/src/shared/api/projectGit.ts | 2 + desktop/src/shared/ui/markdown.test.mjs | 18 +- desktop/src/shared/ui/markdown.tsx | 56 +++++- .../src/shared/ui/markdown/runtimeContext.ts | 1 + desktop/src/shared/ui/markdown/types.ts | 2 + desktop/src/shared/ui/markdown/utils.ts | 7 +- 26 files changed, 1217 insertions(+), 118 deletions(-) create mode 100644 desktop/src-tauri/src/commands/project_git_focus.rs create mode 100644 desktop/src/features/projects/lib/projectDetailSearch.test.mjs create mode 100644 desktop/src/features/projects/lib/projectDetailSearch.ts create mode 100644 desktop/src/features/projects/lib/projectRepoSnapshotTarget.test.mjs create mode 100644 desktop/src/features/projects/lib/projectRepoSnapshotTarget.ts create mode 100644 desktop/src/features/projects/lib/repoFileLink.test.mjs create mode 100644 desktop/src/features/projects/lib/repoFileLink.ts create mode 100644 desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs create mode 100644 desktop/src/features/projects/lib/repositoryDeepLinkTarget.ts create mode 100644 desktop/src/features/projects/ui/projectDetailHelpers.test.mjs diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a048ad24af..928aa301b5 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -43,6 +43,7 @@ mod project_git; mod project_git_branches; mod project_git_diff; mod project_git_exec; +mod project_git_focus; mod project_git_push; mod project_git_workflow; mod project_repo_paths; diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a0507..cc6238af3c 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -2,6 +2,7 @@ use super::project_git_exec::{ build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, GitAuthConfig, }; +use super::project_git_focus::{clean_repository_focus_path, select_repository_tree_lines}; use super::project_git_push::push_project_local_repository_blocking; use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; use crate::app_state::AppState; @@ -313,9 +314,10 @@ fn parse_ls_tree( repo_dir: &std::path::Path, output: &str, latest_commit_by_path: &std::collections::HashMap, + focus_path: Option<&str>, ) -> Vec { - output - .lines() + select_repository_tree_lines(output, focus_path, 250, 250) + .into_iter() .filter_map(|line| { let (meta, path) = line.split_once('\t')?; let mut parts = meta.split_whitespace(); @@ -339,7 +341,6 @@ fn parse_ls_tree( latest_commit: latest_commit_by_path.get(path).cloned(), }) }) - .take(250) .collect() } @@ -348,6 +349,7 @@ fn snapshot_from_repo( auth: &GitAuthConfig, branch_name: Option<&str>, base_branch: Option<&str>, + focus_path: Option<&str>, ) -> ProjectRepoSnapshotInfo { let latest_commit = run_git( &["log", "-1", "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s"], @@ -397,10 +399,13 @@ fn snapshot_from_repo( ) .map(|output| parse_latest_commit_by_path(&output)) .unwrap_or_default(); - - run_git(&["ls-tree", "-r", "--long", "HEAD"], Some(repo_dir), auth) - .map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path)) - .unwrap_or_default() + run_git( + &["ls-tree", "-r", "--long", "-z", "HEAD"], + Some(repo_dir), + auth, + ) + .map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path, focus_path)) + .unwrap_or_default() } else { Vec::new() }; @@ -712,6 +717,7 @@ pub async fn get_project_repo_snapshot( base_branch: Option, target_ref: Option, target_commit: Option, + target_path: Option, state: State<'_, AppState>, ) -> Result { validate_workspace_clone_url(&clone_url, &state)?; @@ -719,6 +725,7 @@ pub async fn get_project_repo_snapshot( let branch = clean_branch(default_branch); let base_branch = clean_branch(base_branch); let target_ref = clean_target_ref(target_ref); + let target_path = clean_repository_focus_path(target_path); let target_commit = target_commit .map(|value| value.to_ascii_lowercase()) .filter(|value| matches!(value.len(), 40 | 64)) @@ -783,8 +790,13 @@ pub async fn get_project_repo_snapshot( } } - let snapshot = - snapshot_from_repo(&repo_dir, &auth, branch.as_deref(), base_branch.as_deref()); + let snapshot = snapshot_from_repo( + &repo_dir, + &auth, + branch.as_deref(), + base_branch.as_deref(), + target_path.as_deref(), + ); Ok(snapshot) }) .await diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index 186c1a1cf6..28baea7826 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -240,7 +240,7 @@ pub(crate) fn clean_branch(value: Option) -> Option { pub(crate) fn clean_target_ref(value: Option) -> Option { let value = value?.trim().to_string(); - for prefix in ["refs/tags/", "refs/nostr/"] { + for prefix in ["refs/heads/", "refs/tags/", "refs/nostr/"] { if let Some(name) = value.strip_prefix(prefix) { let clean_name = clean_branch(Some(name.to_string()))?; return (clean_name == name).then_some(format!("{prefix}{clean_name}")); @@ -375,7 +375,11 @@ mod tests { } #[test] - fn clean_target_ref_accepts_only_tags_and_pull_request_refs() { + fn clean_target_ref_accepts_heads_tags_and_pull_request_refs() { + assert_eq!( + clean_target_ref(Some("refs/heads/feature/x-1".into())), + Some("refs/heads/feature/x-1".to_string()) + ); assert_eq!( clean_target_ref(Some("refs/tags/v1.0.0".into())), Some("refs/tags/v1.0.0".to_string()) @@ -384,7 +388,7 @@ mod tests { clean_target_ref(Some("refs/nostr/abc123".into())), Some("refs/nostr/abc123".to_string()) ); - assert_eq!(clean_target_ref(Some("refs/heads/main".into())), None); + assert_eq!(clean_target_ref(Some("refs/heads/../main".into())), None); assert_eq!(clean_target_ref(Some("refs/tags/../main".into())), None); } diff --git a/desktop/src-tauri/src/commands/project_git_focus.rs b/desktop/src-tauri/src/commands/project_git_focus.rs new file mode 100644 index 0000000000..18549c8094 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_focus.rs @@ -0,0 +1,150 @@ +const MAX_FOCUS_PATH_BYTES: usize = 4096; + +pub(crate) fn clean_repository_focus_path(value: Option) -> Option { + let value = value?; + if value.is_empty() + || value.len() > MAX_FOCUS_PATH_BYTES + || value.starts_with(['/', '\\']) + || value.ends_with('/') + || value.contains('\\') + || value.chars().any(char::is_control) + || value + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return None; + } + Some(value) +} + +fn tree_line_path(line: &str) -> Option<&str> { + line.split_once('\t').map(|(_, path)| path) +} + +pub(crate) fn select_repository_tree_lines<'a>( + output: &'a str, + focus_path: Option<&str>, + base_limit: usize, + focus_limit: usize, +) -> Vec<&'a str> { + let records: Vec<_> = output + .split_terminator('\0') + .filter(|record| !record.is_empty()) + .collect(); + let mut selected: Vec<_> = records.iter().take(base_limit).copied().collect(); + let Some(focus_path) = focus_path else { + return selected; + }; + let directory_prefix = format!("{focus_path}/"); + selected.extend( + records + .iter() + .skip(base_limit) + .filter(|line| { + tree_line_path(line) + .is_some_and(|path| path == focus_path || path.starts_with(&directory_prefix)) + }) + .take(focus_limit) + .copied(), + ); + selected +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focus_path_accepts_safe_repository_coordinates() { + assert_eq!( + clean_repository_focus_path(Some("GUIDES/setup/install.md".into())).as_deref(), + Some("GUIDES/setup/install.md") + ); + } + + #[test] + fn focus_path_rejects_absolute_traversal_ambiguous_and_control_paths() { + for value in [ + "", + "/etc/passwd", + "../README.md", + "docs/../README.md", + "docs//README.md", + "docs/./README.md", + "docs\\README.md", + "docs/README.md/", + "docs/\u{0}README.md", + ] { + assert_eq!( + clean_repository_focus_path(Some(value.into())), + None, + "{value:?}" + ); + } + } + + #[test] + fn focused_file_outside_base_cap_is_appended_once() { + let output = [ + "100644 blob a 1\tA.txt", + "100644 blob b 1\tB.txt", + "100644 blob c 1\tGUIDES/RUNBOOK.md", + ] + .join("\0"); + assert_eq!( + select_repository_tree_lines(&output, Some("GUIDES/RUNBOOK.md"), 2, 20), + vec![ + "100644 blob a 1\tA.txt", + "100644 blob b 1\tB.txt", + "100644 blob c 1\tGUIDES/RUNBOOK.md", + ] + ); + } + + #[test] + fn focused_directory_adds_descendants_without_partial_prefix_matches() { + let output = [ + "100644 blob a 1\tA.txt", + "100644 blob b 1\tGUIDES.md", + "100644 blob c 1\tGUIDES/setup/install.md", + "100644 blob d 1\tGUIDES/RUNBOOK.md", + ] + .join("\0"); + assert_eq!( + select_repository_tree_lines(&output, Some("GUIDES"), 1, 20), + vec![ + "100644 blob a 1\tA.txt", + "100644 blob c 1\tGUIDES/setup/install.md", + "100644 blob d 1\tGUIDES/RUNBOOK.md", + ] + ); + } + + #[test] + fn nul_delimited_non_ascii_path_matches_without_git_c_quoting() { + let output = ["100644 blob a 1\tA.txt", "100644 blob b 1\tcafé.txt"].join("\0"); + assert_eq!( + select_repository_tree_lines(&output, Some("café.txt"), 1, 20), + vec!["100644 blob a 1\tA.txt", "100644 blob b 1\tcafé.txt",] + ); + } + + #[test] + fn focused_entries_respect_their_own_cap() { + let output = [ + "100644 blob a 1\tA.txt", + "100644 blob b 1\tdocs/1.md", + "100644 blob c 1\tdocs/2.md", + "100644 blob d 1\tdocs/3.md", + ] + .join("\0"); + assert_eq!( + select_repository_tree_lines(&output, Some("docs"), 1, 2), + vec![ + "100644 blob a 1\tA.txt", + "100644 blob b 1\tdocs/1.md", + "100644 blob c 1\tdocs/2.md", + ] + ); + } +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..72754fdc28 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -97,6 +97,8 @@ export function useAppNavigation() { commitHash?: string; pullRequestId?: string; issueId?: string; + repoRef?: string; + repoPath?: string; }, ) => commitNavigation( @@ -113,6 +115,8 @@ export function useAppNavigation() { ? { pullRequestId: behavior.pullRequestId } : {}), ...(behavior?.issueId ? { issueId: behavior.issueId } : {}), + ...(behavior?.repoRef ? { repoRef: behavior.repoRef } : {}), + ...(behavior?.repoPath ? { repoPath: behavior.repoPath } : {}), }, }, behavior, diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 3ce58efa8c..c453c3a40e 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { validateProjectDetailSearch } from "@/features/projects/lib/projectDetailSearch"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,21 +12,14 @@ const ProjectDetailScreen = React.lazy(async () => { export const Route = createFileRoute("/projects/$projectId")({ component: ProjectDetailRouteComponent, - validateSearch: (search: Record) => ({ - commitHash: - typeof search.commitHash === "string" ? search.commitHash : undefined, - pullRequestId: - typeof search.pullRequestId === "string" - ? search.pullRequestId - : undefined, - issueId: typeof search.issueId === "string" ? search.issueId : undefined, - }), + validateSearch: validateProjectDetailSearch, }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId } = Route.useSearch(); + const { commitHash, pullRequestId, issueId, repoRef, repoPath } = + Route.useSearch(); return ( }> @@ -34,6 +28,8 @@ function ProjectDetailRouteComponent() { issueId={issueId} projectId={projectId} pullRequestId={pullRequestId} + repoPath={repoPath} + repoRef={repoRef} /> ); diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d479..d055fb564d 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -10,7 +10,6 @@ import { getProjectLocalRepoDiff, getProjectRepoDiff, getProjectLocalRepoSnapshot, - getProjectRepoSnapshot, listProjectLocalRepositories, } from "@/shared/api/projectGit"; import { @@ -40,6 +39,7 @@ import type { } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; import { resolveProjectDefaultBranch } from "./lib/projectBranches"; +import { fetchProjectRepoSnapshot } from "./lib/projectRepoSnapshotTarget"; import { effectiveCloneUrls } from "./lib/projectCloneUrl"; import type { ProjectIssue } from "./projectIssues.mjs"; import { projectIssueEventsToIssues } from "./projectIssues.mjs"; @@ -564,28 +564,6 @@ async function createProjectIssueComment({ ); } -async function fetchProjectRepoSnapshot( - project: Project, - branchName?: string | null, - pullRequest?: ProjectPullRequest | null, - tag?: { name: string; commit: string } | null, -): Promise { - const cloneUrl = pullRequest?.cloneUrls[0] ?? project.cloneUrls[0]; - if (!cloneUrl) return null; - - return getProjectRepoSnapshot({ - cloneUrl, - defaultBranch: branchName ?? project.defaultBranch, - baseBranch: project.defaultBranch, - targetCommit: tag?.commit ?? pullRequest?.commit ?? null, - targetRef: tag - ? `refs/tags/${tag.name}` - : pullRequest - ? `refs/nostr/${pullRequest.id}` - : null, - }); -} - async function fetchProjectRepoDiff( project: Project, branchName?: string | null, @@ -718,6 +696,7 @@ export function useProjectRepoSnapshotQuery( branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, + repositoryTarget?: { ref: string; path: string } | null, ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; @@ -732,6 +711,9 @@ export function useProjectRepoSnapshotQuery( pullRequest?.commit ?? "none", tag?.name ?? "no-tag", tag?.commit ?? "no-tag-commit", + repositoryTarget + ? `${repositoryTarget.ref}${repositoryTarget.path}` + : "no-repository-target", ], queryFn: () => { if (!project) throw new Error("No project selected."); @@ -740,6 +722,7 @@ export function useProjectRepoSnapshotQuery( selectedBranch, pullRequest, tag, + repositoryTarget, ); }, staleTime: 30_000, diff --git a/desktop/src/features/projects/lib/projectDetailSearch.test.mjs b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs new file mode 100644 index 0000000000..b37a254178 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { validateProjectDetailSearch } from "./projectDetailSearch.ts"; + +const COMMIT = "a".repeat(40); + +test("validates existing work-item search and repository target atomically", () => { + assert.deepEqual( + validateProjectDetailSearch({ + commitHash: "commit", + pullRequestId: "pr", + issueId: "issue", + repoRef: COMMIT, + repoPath: "src/main.ts", + }), + { + commitHash: "commit", + pullRequestId: "pr", + issueId: "issue", + repoRef: COMMIT, + repoPath: "src/main.ts", + }, + ); +}); + +test("drops incomplete or unsafe repository target search", () => { + assert.deepEqual(validateProjectDetailSearch({ repoRef: "main" }), { + commitHash: undefined, + pullRequestId: undefined, + issueId: undefined, + repoRef: undefined, + repoPath: undefined, + }); + assert.deepEqual( + validateProjectDetailSearch({ repoRef: "main", repoPath: "../secret" }), + { + commitHash: undefined, + pullRequestId: undefined, + issueId: undefined, + repoRef: undefined, + repoPath: undefined, + }, + ); +}); diff --git a/desktop/src/features/projects/lib/projectDetailSearch.ts b/desktop/src/features/projects/lib/projectDetailSearch.ts new file mode 100644 index 0000000000..616b21a041 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -0,0 +1,37 @@ +import { normalizeRepoFilePath, parseRepoFileRef } from "./repoFileLink"; + +export type ProjectDetailSearch = { + commitHash: string | undefined; + pullRequestId: string | undefined; + issueId: string | undefined; + repoRef: string | undefined; + repoPath: string | undefined; +}; + +export function validateProjectDetailSearch( + search: Record, +): ProjectDetailSearch { + const commitHash = + typeof search.commitHash === "string" ? search.commitHash : undefined; + const pullRequestId = + typeof search.pullRequestId === "string" ? search.pullRequestId : undefined; + const issueId = + typeof search.issueId === "string" ? search.issueId : undefined; + const parsedRef = + typeof search.repoRef === "string" + ? parseRepoFileRef(search.repoRef) + : null; + const repoPath = + typeof search.repoPath === "string" + ? normalizeRepoFilePath(search.repoPath) + : null; + const hasRepositoryTarget = Boolean(parsedRef && repoPath); + + return { + commitHash, + pullRequestId, + issueId, + repoRef: hasRepositoryTarget ? parsedRef?.value : undefined, + repoPath: hasRepositoryTarget ? (repoPath ?? undefined) : undefined, + }; +} diff --git a/desktop/src/features/projects/lib/projectRepoSnapshotTarget.test.mjs b/desktop/src/features/projects/lib/projectRepoSnapshotTarget.test.mjs new file mode 100644 index 0000000000..ece86de77e --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoSnapshotTarget.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + projectRepoSnapshotCloneUrl, + projectRepoSnapshotTarget, +} from "./projectRepoSnapshotTarget.ts"; + +const COMMIT = "a".repeat(40); + +test("explicit branch target outranks selected branch, tag, and pull request", () => { + assert.deepEqual( + projectRepoSnapshotTarget({ + selectedBranch: "main", + projectDefaultBranch: "main", + pullRequest: { id: "pr-1", commit: "b".repeat(40) }, + tag: { name: "v1", commit: "c".repeat(40) }, + repositoryRef: "feature/repo-links", + }), + { + defaultBranch: "feature/repo-links", + baseBranch: "main", + targetRef: "refs/heads/feature/repo-links", + targetCommit: null, + }, + ); +}); + +test("explicit commit target outranks branch, tag, and pull request", () => { + assert.deepEqual( + projectRepoSnapshotTarget({ + selectedBranch: "main", + projectDefaultBranch: "main", + pullRequest: { id: "pr-1", commit: "b".repeat(40) }, + tag: { name: "v1", commit: "c".repeat(40) }, + repositoryRef: COMMIT, + }), + { + defaultBranch: "main", + baseBranch: "main", + targetRef: null, + targetCommit: COMMIT, + }, + ); +}); + +test("existing tag and pull-request precedence is preserved without a repository link", () => { + assert.deepEqual( + projectRepoSnapshotTarget({ + selectedBranch: "release", + projectDefaultBranch: "main", + pullRequest: { id: "pr-1", commit: "b".repeat(40) }, + tag: { name: "v1", commit: "c".repeat(40) }, + repositoryRef: null, + }), + { + defaultBranch: "release", + baseBranch: "main", + targetRef: "refs/tags/v1", + targetCommit: "c".repeat(40), + }, + ); +}); + +test("explicit repository targets use the canonical project clone URL", () => { + assert.equal( + projectRepoSnapshotCloneUrl({ + projectCloneUrls: ["https://relay.example/git/owner/project"], + pullRequestCloneUrls: ["https://fork.example/git/owner/fork"], + repositoryTarget: { ref: "main", path: "README.md" }, + }), + "https://relay.example/git/owner/project", + ); +}); + +test("ordinary pull request snapshots retain pull request clone precedence", () => { + assert.equal( + projectRepoSnapshotCloneUrl({ + projectCloneUrls: ["https://relay.example/git/owner/project"], + pullRequestCloneUrls: ["https://fork.example/git/owner/fork"], + repositoryTarget: null, + }), + "https://fork.example/git/owner/fork", + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoSnapshotTarget.ts b/desktop/src/features/projects/lib/projectRepoSnapshotTarget.ts new file mode 100644 index 0000000000..0482f5e27e --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoSnapshotTarget.ts @@ -0,0 +1,84 @@ +import { getProjectRepoSnapshot } from "@/shared/api/projectGit"; +import type { ProjectRepoSnapshot } from "@/shared/api/types"; +import { parseRepoFileRef } from "./repoFileLink"; + +type PullRequestTarget = { + cloneUrls: string[]; + id: string; + commit: string | null; +}; +type TagTarget = { name: string; commit: string }; + +export function projectRepoSnapshotCloneUrl(input: { + projectCloneUrls: readonly string[]; + pullRequestCloneUrls?: readonly string[]; + repositoryTarget?: { ref: string; path: string } | null; +}): string | undefined { + return input.repositoryTarget + ? input.projectCloneUrls[0] + : (input.pullRequestCloneUrls?.[0] ?? input.projectCloneUrls[0]); +} + +export function projectRepoSnapshotTarget(input: { + selectedBranch: string | null | undefined; + projectDefaultBranch: string; + pullRequest?: PullRequestTarget | null; + tag?: TagTarget | null; + repositoryRef?: string | null; +}) { + const explicitRef = input.repositoryRef + ? parseRepoFileRef(input.repositoryRef) + : null; + if (explicitRef?.kind === "branch") { + return { + defaultBranch: explicitRef.value, + baseBranch: input.projectDefaultBranch, + targetRef: `refs/heads/${explicitRef.value}`, + targetCommit: null, + }; + } + if (explicitRef?.kind === "commit") { + return { + defaultBranch: input.selectedBranch ?? input.projectDefaultBranch, + baseBranch: input.projectDefaultBranch, + targetRef: null, + targetCommit: explicitRef.value, + }; + } + return { + defaultBranch: input.selectedBranch ?? input.projectDefaultBranch, + baseBranch: input.projectDefaultBranch, + targetRef: input.tag + ? `refs/tags/${input.tag.name}` + : input.pullRequest + ? `refs/nostr/${input.pullRequest.id}` + : null, + targetCommit: input.tag?.commit ?? input.pullRequest?.commit ?? null, + }; +} + +export async function fetchProjectRepoSnapshot( + project: { cloneUrls: string[]; defaultBranch: string }, + selectedBranch?: string | null, + pullRequest?: PullRequestTarget | null, + tag?: TagTarget | null, + repositoryTarget?: { ref: string; path: string } | null, +): Promise { + const cloneUrl = projectRepoSnapshotCloneUrl({ + projectCloneUrls: project.cloneUrls, + pullRequestCloneUrls: pullRequest?.cloneUrls, + repositoryTarget, + }); + if (!cloneUrl) return null; + return getProjectRepoSnapshot({ + cloneUrl, + targetPath: repositoryTarget?.path ?? null, + ...projectRepoSnapshotTarget({ + selectedBranch, + projectDefaultBranch: project.defaultBranch, + pullRequest, + tag, + repositoryRef: repositoryTarget?.ref, + }), + }); +} diff --git a/desktop/src/features/projects/lib/repoFileLink.test.mjs b/desktop/src/features/projects/lib/repoFileLink.test.mjs new file mode 100644 index 0000000000..70f10210fe --- /dev/null +++ b/desktop/src/features/projects/lib/repoFileLink.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildRepoFileLink, + isRepoFileLink, + parseRepoFileLink, + repoFileLinkNavigation, +} from "./repoFileLink.ts"; + +const OWNER = "A".repeat(64); +const COMMIT = "b".repeat(40); + +function requireParsed(result) { + assert.equal(result.ok, true, result.ok ? undefined : result.reason); + return result.value; +} + +test("buildRepoFileLink round-trips a branch and encoded repository path", () => { + const href = buildRepoFileLink({ + ownerPubkey: OWNER, + repoId: "buzz.desktop", + ref: "feature/repo-links", + path: "PLANS/Plan 1.md", + }); + assert.equal( + href, + `buzz://repo?owner=${OWNER.toLowerCase()}&repo=buzz.desktop&ref=feature%2Frepo-links&path=PLANS%2FPlan+1.md`, + ); + + assert.deepEqual(requireParsed(parseRepoFileLink(href)), { + ownerPubkey: OWNER.toLowerCase(), + repoId: "buzz.desktop", + projectId: `${OWNER.toLowerCase()}:buzz.desktop`, + ref: "feature/repo-links", + refKind: "branch", + path: "PLANS/Plan 1.md", + }); +}); + +test("parseRepoFileLink classifies a full commit hash", () => { + const parsed = requireParsed( + parseRepoFileLink( + `buzz://repo?owner=${OWNER}&repo=buzz&ref=${COMMIT}&path=README.md`, + ), + ); + assert.equal(parsed.ref, COMMIT); + assert.equal(parsed.refKind, "commit"); +}); + +test("repoFileLinkNavigation maps stable identity to project route search", () => { + const parsed = requireParsed( + parseRepoFileLink( + `buzz://repo?owner=${OWNER}&repo=buzz&ref=main&path=GUIDES%2FRUNBOOK.md`, + ), + ); + assert.deepEqual(repoFileLinkNavigation(parsed), { + projectId: `${OWNER.toLowerCase()}:buzz`, + repoPath: "GUIDES/RUNBOOK.md", + repoRef: "main", + }); +}); + +test("parseRepoFileLink rejects malformed identity and missing coordinates", () => { + const cases = [ + ["https://example.com", "wrong-scheme"], + [ + `buzz://message?owner=${OWNER}&repo=buzz&ref=main&path=README.md`, + "wrong-host", + ], + ["buzz://repo?repo=buzz&ref=main&path=README.md", "missing-owner"], + [ + `buzz://repo?owner=abc&repo=buzz&ref=main&path=README.md`, + "invalid-owner", + ], + [`buzz://repo?owner=${OWNER}&ref=main&path=README.md`, "missing-repo"], + [ + `buzz://repo?owner=${OWNER}&repo=.hidden&ref=main&path=README.md`, + "invalid-repo", + ], + [ + `buzz://repo?owner=${OWNER}&repo=a..b&ref=main&path=README.md`, + "invalid-repo", + ], + [ + `buzz://repo?owner=${OWNER}&repo=a/b&ref=main&path=README.md`, + "invalid-repo", + ], + [`buzz://repo?owner=${OWNER}&repo=buzz&path=README.md`, "missing-ref"], + [ + `buzz://repo?owner=${OWNER}&repo=buzz&ref=feature%2F..%2Fmain&path=README.md`, + "invalid-ref", + ], + [ + `buzz://repo?owner=${OWNER}&repo=buzz&ref=bad+ref&path=README.md`, + "invalid-ref", + ], + [`buzz://repo?owner=${OWNER}&repo=buzz&ref=main`, "missing-path"], + ]; + + for (const [href, reason] of cases) { + const result = parseRepoFileLink(href); + assert.equal(result.ok, false, href); + assert.equal(result.ok === false && result.reason, reason, href); + } +}); + +test("parseRepoFileLink rejects credentials, ports, pathnames, fragments, and unknown parameters", () => { + const base = `owner=${OWNER}&repo=buzz&ref=main&path=README.md`; + const cases = [ + `buzz://user@repo?${base}`, + `buzz://repo:99?${base}`, + `buzz://repo/open?${base}`, + `buzz://repo?${base}#fragment`, + `buzz://repo?${base}&extra=value`, + ]; + for (const href of cases) { + const result = parseRepoFileLink(href); + assert.equal(result.ok, false, href); + assert.equal( + result.ok === false && result.reason, + "invalid-structure", + href, + ); + } +}); + +test("parseRepoFileLink rejects absolute, traversal, ambiguous, and control paths", () => { + for (const path of [ + "/etc/passwd", + "../README.md", + "docs/../README.md", + "docs//README.md", + "docs/./README.md", + "docs\\README.md", + "docs/README.md/", + `docs/${String.fromCharCode(0)}README.md`, + ]) { + const href = buildUnsafe(path); + const result = parseRepoFileLink(href); + assert.equal(result.ok, false, path); + assert.equal(result.ok === false && result.reason, "invalid-path", path); + } +}); + +function buildUnsafe(path) { + const params = new URLSearchParams({ + owner: OWNER, + repo: "buzz", + ref: "main", + path, + }); + return `buzz://repo?${params.toString()}`; +} + +test("isRepoFileLink recognizes the namespace before full validation", () => { + assert.equal(isRepoFileLink(`buzz://repo?owner=${OWNER}`), true); + assert.equal(isRepoFileLink("buzz://repo"), true); + assert.equal(isRepoFileLink("buzz://message?channel=x&id=y"), false); + assert.equal(isRepoFileLink("https://example.com"), false); + assert.equal(isRepoFileLink(undefined), false); +}); diff --git a/desktop/src/features/projects/lib/repoFileLink.ts b/desktop/src/features/projects/lib/repoFileLink.ts new file mode 100644 index 0000000000..3fa9ad6867 --- /dev/null +++ b/desktop/src/features/projects/lib/repoFileLink.ts @@ -0,0 +1,181 @@ +import { normalizeProjectBranchName } from "./projectBranches"; + +const REPO_LINK_SCHEME = "buzz:"; +const REPO_LINK_HOST = "repo"; +const OWNER_PATTERN = /^[0-9a-fA-F]{64}$/; +const REPO_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; +const COMMIT_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/; +const MAX_REPOSITORY_PATH_LENGTH = 4096; + +export type RepoFileLinkRefKind = "branch" | "commit"; + +export type RepoFileLinkInput = { + ownerPubkey: string; + repoId: string; + ref: string; + path: string; +}; + +export type ParsedRepoFileLink = { + ownerPubkey: string; + repoId: string; + projectId: string; + ref: string; + refKind: RepoFileLinkRefKind; + path: string; +}; + +export type RepoFileLinkParseResult = + | { ok: true; value: ParsedRepoFileLink } + | { ok: false; reason: string }; + +function normalizeRepoId(value: string): string | null { + return REPO_ID_PATTERN.test(value) && + !value.startsWith(".") && + !value.includes("..") + ? value + : null; +} + +export function parseRepoFileRef( + value: string, +): { kind: RepoFileLinkRefKind; value: string } | null { + const trimmed = value.trim(); + if (COMMIT_PATTERN.test(trimmed)) { + return { kind: "commit", value: trimmed.toLowerCase() }; + } + const branch = normalizeProjectBranchName(trimmed); + return branch ? { kind: "branch", value: branch } : null; +} + +function containsControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +export function normalizeRepoFilePath(value: string): string | null { + if ( + !value || + value.length > MAX_REPOSITORY_PATH_LENGTH || + value.startsWith("/") || + value.startsWith("\\") || + value.endsWith("/") || + value.includes("\\") || + containsControlCharacter(value) + ) { + return null; + } + const segments = value.split("/"); + if ( + segments.some((segment) => !segment || segment === "." || segment === "..") + ) { + return null; + } + return value; +} + +function oneParam(parsed: URL, name: string): string | null { + const values = parsed.searchParams.getAll(name); + return values.length === 1 ? values[0] : null; +} + +export function buildRepoFileLink(input: RepoFileLinkInput): string { + const ownerPubkey = OWNER_PATTERN.test(input.ownerPubkey) + ? input.ownerPubkey.toLowerCase() + : null; + const repoId = normalizeRepoId(input.repoId); + const parsedRef = parseRepoFileRef(input.ref); + const path = normalizeRepoFilePath(input.path); + if (!ownerPubkey) throw new Error("buildRepoFileLink: invalid owner pubkey"); + if (!repoId) throw new Error("buildRepoFileLink: invalid repository id"); + if (!parsedRef) throw new Error("buildRepoFileLink: invalid repository ref"); + if (!path) throw new Error("buildRepoFileLink: invalid repository path"); + + const params = new URLSearchParams({ + owner: ownerPubkey, + repo: repoId, + ref: parsedRef.value, + path, + }); + return `${REPO_LINK_SCHEME}//${REPO_LINK_HOST}?${params.toString()}`; +} + +export function parseRepoFileLink(url: string): RepoFileLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + if (parsed.protocol !== REPO_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + if (parsed.hostname !== REPO_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + + const allowedParams = new Set(["owner", "repo", "ref", "path"]); + if ( + parsed.username || + parsed.password || + parsed.port || + parsed.pathname || + parsed.hash || + [...parsed.searchParams.keys()].some((name) => !allowedParams.has(name)) + ) { + return { ok: false, reason: "invalid-structure" }; + } + + const ownerRaw = oneParam(parsed, "owner"); + if (!ownerRaw) return { ok: false, reason: "missing-owner" }; + if (!OWNER_PATTERN.test(ownerRaw)) { + return { ok: false, reason: "invalid-owner" }; + } + const ownerPubkey = ownerRaw.toLowerCase(); + + const repoRaw = oneParam(parsed, "repo"); + if (!repoRaw) return { ok: false, reason: "missing-repo" }; + const repoId = normalizeRepoId(repoRaw); + if (!repoId) return { ok: false, reason: "invalid-repo" }; + + const refRaw = oneParam(parsed, "ref"); + if (!refRaw) return { ok: false, reason: "missing-ref" }; + const parsedRef = parseRepoFileRef(refRaw); + if (!parsedRef) return { ok: false, reason: "invalid-ref" }; + + const pathRaw = oneParam(parsed, "path"); + if (!pathRaw) return { ok: false, reason: "missing-path" }; + const path = normalizeRepoFilePath(pathRaw); + if (!path) return { ok: false, reason: "invalid-path" }; + + return { + ok: true, + value: { + ownerPubkey, + repoId, + projectId: `${ownerPubkey}:${repoId}`, + ref: parsedRef.value, + refKind: parsedRef.kind, + path, + }, + }; +} + +export function isRepoFileLink(href: string | null | undefined): boolean { + if (!href) return false; + return href === "buzz://repo" || href.startsWith("buzz://repo?"); +} + +export function repoFileLinkNavigation(link: ParsedRepoFileLink): { + projectId: string; + repoRef: string; + repoPath: string; +} { + return { + projectId: link.projectId, + repoRef: link.ref, + repoPath: link.path, + }; +} diff --git a/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs b/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs new file mode 100644 index 0000000000..5670570ac4 --- /dev/null +++ b/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import * as repositoryTarget from "./repositoryDeepLinkTarget.ts"; +import { + projectTabForRepositoryTarget, + repositoryTargetResultKey, + resolveRepositoryDeepLinkTarget, + shouldResolveRepositoryTarget, +} from "./repositoryDeepLinkTarget.ts"; + +const files = [ + { path: "README.md", kind: "blob" }, + { path: "GUIDES/RUNBOOK.md", kind: "blob" }, + { path: "GUIDES/setup/install.md", kind: "blob" }, +]; + +test("resolves an exact repository file", () => { + assert.deepEqual( + resolveRepositoryDeepLinkTarget(files, "GUIDES/RUNBOOK.md"), + { + kind: "file", + file: files[1], + parentPath: "GUIDES", + }, + ); +}); + +test("resolves an existing directory from file prefixes", () => { + assert.deepEqual(resolveRepositoryDeepLinkTarget(files, "GUIDES/setup"), { + kind: "directory", + path: "GUIDES/setup", + }); +}); + +test("does not treat a partial filename prefix as a directory", () => { + assert.deepEqual(resolveRepositoryDeepLinkTarget(files, "GUIDES/RUN"), { + kind: "missing", + }); +}); + +test("repository targets open the files tab while ordinary routes open overview", () => { + assert.equal(projectTabForRepositoryTarget("README.md"), "files"); + assert.equal(projectTabForRepositoryTarget(undefined), "overview"); +}); + +test("failed targets retry when the ref becomes available", () => { + const attempt = { key: "target\0tree", outcome: "error" }; + assert.equal( + shouldResolveRepositoryTarget({ + attempt, + hasError: true, + isLoading: false, + resolutionKey: "target\0tree", + }), + false, + ); + assert.equal( + shouldResolveRepositoryTarget({ + attempt, + hasError: false, + isLoading: false, + resolutionKey: "target\0tree", + }), + true, + ); +}); + +test("resolved targets retry only for a changed repository tree", () => { + const attempt = { key: "target\0tree-a", outcome: "resolved" }; + assert.equal( + shouldResolveRepositoryTarget({ + attempt, + hasError: false, + isLoading: false, + resolutionKey: "target\0tree-a", + }), + false, + ); + assert.equal( + shouldResolveRepositoryTarget({ + attempt, + hasError: false, + isLoading: false, + resolutionKey: "target\0tree-b", + }), + true, + ); +}); + +test("repository target resolution is re-armed when the snapshot file set changes", () => { + const targetKey = "main\0GUIDES/RUNBOOK.md"; + assert.notEqual( + repositoryTargetResultKey(targetKey, "README.md"), + repositoryTargetResultKey(targetKey, "README.md\0GUIDES/RUNBOOK.md"), + ); +}); + +test("repository target failures offer the repository root", () => { + const onClick = () => {}; + const action = repositoryTarget.repositoryRootToastAction?.(onClick); + assert.ok(action, "repository root action is available"); + assert.equal(action.label, "Open repository root"); + assert.equal(action.onClick, onClick); +}); diff --git a/desktop/src/features/projects/lib/repositoryDeepLinkTarget.ts b/desktop/src/features/projects/lib/repositoryDeepLinkTarget.ts new file mode 100644 index 0000000000..42d61a7ed2 --- /dev/null +++ b/desktop/src/features/projects/lib/repositoryDeepLinkTarget.ts @@ -0,0 +1,60 @@ +export type RepositoryDeepLinkTarget = + | { kind: "file"; file: T; parentPath: string } + | { kind: "directory"; path: string } + | { kind: "missing" }; + +export function resolveRepositoryDeepLinkTarget( + files: readonly T[], + targetPath: string, +): RepositoryDeepLinkTarget { + const file = files.find((candidate) => candidate.path === targetPath); + if (file) { + const slash = targetPath.lastIndexOf("/"); + return { + kind: "file", + file, + parentPath: slash >= 0 ? targetPath.slice(0, slash) : "", + }; + } + const directoryPrefix = `${targetPath}/`; + if (files.some((candidate) => candidate.path.startsWith(directoryPrefix))) { + return { kind: "directory", path: targetPath }; + } + return { kind: "missing" }; +} + +export function projectTabForRepositoryTarget( + targetPath: string | undefined, +): "files" | "overview" { + return targetPath ? "files" : "overview"; +} + +export type RepositoryTargetAttempt = { + key: string; + outcome: "error" | "resolved"; +}; + +export function shouldResolveRepositoryTarget(input: { + attempt: RepositoryTargetAttempt | null; + hasError: boolean; + isLoading: boolean; + resolutionKey: string | null; +}): boolean { + if (!input.resolutionKey || input.isLoading) return false; + if (input.attempt?.key !== input.resolutionKey) return true; + return input.attempt.outcome === "error" && !input.hasError; +} + +export function repositoryTargetResultKey( + targetKey: string, + filesKey: string, +): string { + return `${targetKey}\0${filesKey}`; +} + +export function repositoryRootToastAction(onClick: () => void): { + label: string; + onClick: () => void; +} { + return { label: "Open repository root", onClick }; +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 587dd0d11b..9fa9eeb131 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -79,17 +79,19 @@ import { import type { CreateIssueDialogInput } from "./CreateIssueDialog"; import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs"; import { + effectiveProjectRepoSource, PROJECT_TAB_CRUMB_LABELS, projectPeople, pushPullTitle, snapshotHasContent, } from "./projectDetailHelpers"; - type ProjectDetailScreenProps = { commitHash?: string; projectId: string; pullRequestId?: string; issueId?: string; + repoPath?: string; + repoRef?: string; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -99,7 +101,8 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ ] as const; export function ProjectDetailScreen(props: ProjectDetailScreenProps) { - const { commitHash, projectId, pullRequestId, issueId } = props; + const { commitHash, issueId, projectId, pullRequestId, repoPath, repoRef } = + props; const { goChannel, goProject, goProjects } = useAppNavigation(); const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); @@ -154,14 +157,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { () => setSelectedCommitHash(commitHash ?? null), [commitHash], ); - // Bumped when breadcrumb navigation should land on the project Overview - // tab; remounts WorkspaceTabs, which owns the selected-tab state. const [tabsResetKey, setTabsResetKey] = React.useState(0); - // Mirror of the WorkspaceTabs selection so the breadcrumb can name the - // active sub-tab. The Overview (readme) tab is "home" and gets no crumb. const [activeTab, setActiveTab] = React.useState("overview"); - // Commit, PR, and issue details are mutually exclusive views, so opening - // one clears the others. const handleSelectedPullRequestIdChange = React.useCallback( (id: string | null) => { setSelectedPullRequestId(id); @@ -183,6 +180,16 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }, [], ); + const handleGoToProjectHome = React.useCallback(() => { + if (repoPath) { + void goProject(projectId, { replace: true }); + return; + } + setSelectedPullRequestId(null); + setSelectedIssueId(null); + setSelectedCommitHash(null); + setTabsResetKey((key) => key + 1); + }, [goProject, projectId, repoPath]); const issuesQuery = useProjectIssuesQuery(project); const selectedBranchPullRequest = React.useMemo(() => { const projectRepositories = new Set( @@ -209,29 +216,34 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const [repoSource, setRepoSource] = React.useState<"remote" | "local">( "remote", ); + const effectiveRepoSource = effectiveProjectRepoSource(repoSource, repoPath); + React.useEffect(() => { + if (repoPath) setRepoSource("remote"); + }, [repoPath]); const repoSnapshotQuery = useProjectRepoSnapshotQuery( project, activeBranch, selectedTag ? null : selectedBranchPullRequest, activeTag, + repoRef && repoPath ? { ref: repoRef, path: repoPath } : null, ); const repoDiffQuery = useProjectRepoDiffQuery( project, activeBranch, activeRepoPullRequest, - repoSource === "remote", + effectiveRepoSource === "remote", ); const localRepoDiffQuery = useProjectLocalRepoDiffQuery( project, activeCommunity?.reposDir, activeBranch, activeRepoPullRequest, - repoSource === "local" && Boolean(activeRepoPullRequest), + effectiveRepoSource === "local" && Boolean(activeRepoPullRequest), ); const commitDiffQuery = useProjectCommitDiffQuery( project, selectedCommitHash, - repoSource, + effectiveRepoSource, activeCommunity?.reposDir, ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( @@ -269,11 +281,15 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ); const hasRemoteSnapshot = snapshotHasContent(repoSnapshotQuery.data); const displayedRepoDiff = - repoSource === "local" ? localRepoDiffQuery.data : repoDiffQuery.data; + effectiveRepoSource === "local" + ? localRepoDiffQuery.data + : repoDiffQuery.data; const displayedRepoDiffError = - repoSource === "local" ? localRepoDiffQuery.error : repoDiffQuery.error; + effectiveRepoSource === "local" + ? localRepoDiffQuery.error + : repoDiffQuery.error; const displayedRepoDiffLoading = - repoSource === "local" + effectiveRepoSource === "local" ? localRepoDiffQuery.isLoading : repoDiffQuery.isLoading; const branchOptionsWithLocal = projectBranchOptionsFromSync( @@ -299,13 +315,13 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { selectBranch(branch); if ( branch && - repoSource === "local" && + effectiveRepoSource === "local" && branch !== repoSyncStatusQuery.data?.localBranch ) { setRepoSource("remote"); } }, - [repoSource, repoSyncStatusQuery.data?.localBranch, selectBranch], + [effectiveRepoSource, repoSyncStatusQuery.data?.localBranch, selectBranch], ); const handleTagChange = React.useCallback( (tag: string) => { @@ -347,8 +363,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { } toast.success("Remote state refreshed."); }, [repoSnapshotQuery, repoStateQuery, repoSyncStatusQuery]); - // Compact branch + remote/local controls shared by the readme and Files - // tab headers. const filesSourceControls: RepoSourceHeaderControls = { branch: activeBranch ?? "", branchOptions: branchOptionsWithLocal, @@ -363,7 +377,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { deleteBranchDisabled: branchActions.deletePending || Boolean(deleteBranchReason), deleteBranchTitle: deleteBranchReason ?? "Delete this remote branch", - source: selectedTag ? "remote" : repoSource, + source: selectedTag ? "remote" : effectiveRepoSource, onSourceChange: setRepoSource, localDisabled: Boolean(selectedTag) || @@ -727,7 +741,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const selectedIssue = issuesQuery.data?.find((item) => item.id === selectedIssueId) ?? null; const displayedSnapshotCommits = - repoSource === "local" + effectiveRepoSource === "local" ? (localRepoSnapshotQuery.data?.snapshot.commits ?? []) : (repoSnapshotQuery.data?.commits ?? []); const selectedCommit = selectedCommitHash @@ -736,9 +750,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ) ?? null) : null; - // The active work item drives the breadcrumb trail: Projects › project › - // sub-tab › title. `clear` steps back to the item's list tab. Categories - // match the workspace tab labels. const activeWorkItemCrumb = selectedPullRequest ? { category: "Pull Request", @@ -758,18 +769,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { clear: () => setSelectedCommitHash(null), } : null; - // Sub-tab crumb when no work item is open. Overview (readme) is home. const activeTabCrumb = activeWorkItemCrumb ? null : (PROJECT_TAB_CRUMB_LABELS[activeTab] ?? null); - const handleGoToProjectHome = () => { - setSelectedPullRequestId(null); - setSelectedIssueId(null); - setSelectedCommitHash(null); - // Remount the workspace tabs so the project page opens on Overview - // instead of whatever tab the work item left behind. - setTabsResetKey((key) => key + 1); - }; return ( @@ -940,6 +942,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { localSnapshotLoading={localRepoSnapshotQuery.isLoading} onBranchChange={handleBranchChange} onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal} + onOpenRepositoryRoot={handleGoToProjectHome} onOpenTerminal={() => { void handleOpenTerminal(); }} @@ -959,7 +962,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { pullRequestsError={pullRequestsQuery.error} pullRequestsLoading={pullRequestsQuery.isLoading} repoContributors={repoContributors} - repoSource={repoSource} + repoSource={effectiveRepoSource} + repositoryPath={repoPath} + repositoryRef={repoRef} selectedCommitHash={selectedCommitHash} selectedIssueId={selectedIssueId} selectedPullRequestId={selectedPullRequestId} diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 93965b29de..fcf97a04bf 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -16,16 +16,26 @@ import { FileType, FileVideo, FolderGit2, + GitBranch, + GitCommit, Package, Settings, Terminal, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import type { ProjectRepoFile, ProjectRepoSnapshot, } from "@/features/projects/hooks"; +import { + type RepositoryTargetAttempt, + repositoryRootToastAction, + repositoryTargetResultKey, + resolveRepositoryDeepLinkTarget, + shouldResolveRepositoryTarget, +} from "@/features/projects/lib/repositoryDeepLinkTarget"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -606,6 +616,21 @@ function FileContentPanel({ ); } +function RepositoryTargetRef({ value }: { value: string }) { + const isCommit = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value); + const Icon = isCommit ? GitCommit : GitBranch; + const label = isCommit ? value.slice(0, 7) : value; + return ( + + + {label} + + ); +} + export function RepositoryFilesPanel({ files, snapshot, @@ -614,6 +639,9 @@ export function RepositoryFilesPanel({ profiles, fallbackAuthorPubkey, sourceControls, + targetPath, + targetRef, + onOpenRepositoryRoot, }: { files: ProjectRepoFile[]; snapshot: ProjectRepoSnapshot | null | undefined; @@ -623,6 +651,9 @@ export function RepositoryFilesPanel({ fallbackAuthorPubkey?: string; /** Branch picker + remote/local toggle rendered in the panel header. */ sourceControls?: RepoSourceHeaderControls; + targetPath?: string; + targetRef?: string; + onOpenRepositoryRoot: () => void; }) { const [currentPath, setCurrentPath] = React.useState(""); const [selectedFile, setSelectedFile] = @@ -675,6 +706,61 @@ export function RepositoryFilesPanel({ setSelectedFile(null); }, [filesKey]); + const targetAttemptRef = React.useRef(null); + const targetKey = targetPath ? `${targetRef ?? ""}\u0000${targetPath}` : null; + const targetResolutionKey = targetKey + ? repositoryTargetResultKey(targetKey, filesKey) + : null; + React.useEffect(() => { + if (!targetResolutionKey) { + targetAttemptRef.current = null; + return; + } + if ( + !shouldResolveRepositoryTarget({ + attempt: targetAttemptRef.current, + hasError: Boolean(error), + isLoading, + resolutionKey: targetResolutionKey, + }) + ) { + return; + } + setCurrentPath(""); + setSelectedFile(null); + if (error) { + targetAttemptRef.current = { key: targetResolutionKey, outcome: "error" }; + toast.error("Could not open repository link.", { + action: repositoryRootToastAction(onOpenRepositoryRoot), + description: "The requested repository ref is unavailable.", + }); + return; + } + targetAttemptRef.current = { + key: targetResolutionKey, + outcome: "resolved", + }; + const target = resolveRepositoryDeepLinkTarget(files, targetPath ?? ""); + if (target.kind === "file") { + setCurrentPath(target.parentPath); + setSelectedFile(target.file); + } else if (target.kind === "directory") { + setCurrentPath(target.path); + } else { + toast.error("Repository path not found.", { + action: repositoryRootToastAction(onOpenRepositoryRoot), + description: targetPath, + }); + } + }, [ + error, + files, + isLoading, + onOpenRepositoryRoot, + targetPath, + targetResolutionKey, + ]); + // Loading/error/empty states keep the header controls visible — the // remote/local toggle must stay reachable when one source fails to load. const stateMessage = isLoading @@ -685,7 +771,7 @@ export function RepositoryFilesPanel({ ? "No files have been pushed yet." : null; if (stateMessage) { - if (!sourceControls) { + if (!sourceControls && !targetRef) { return (
{stateMessage} @@ -695,25 +781,31 @@ export function RepositoryFilesPanel({ return (
- - -
- -
+ {targetRef ? ( + + ) : sourceControls ? ( + <> + + +
+ +
+ + ) : null}
{stateMessage}
@@ -735,7 +827,9 @@ export function RepositoryFilesPanel({ return (
- {sourceControls ? ( + {targetRef ? ( + + ) : sourceControls ? ( <> )} - {sourceControls && pathSegments.length > 0 ? ( + {(sourceControls || targetRef) && pathSegments.length > 0 ? ( <> setCurrentPath("")}> @@ -778,7 +872,7 @@ export function RepositoryFilesPanel({ ); })} - {sourceControls ? ( + {sourceControls && !targetRef ? (
diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 4dc97635d4..43783e13ed 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -21,6 +21,7 @@ import { commitAuthorPubkeysFromPullRequests, type ViewerGitIdentity, } from "@/features/projects/lib/projectContributorMatching"; +import { projectTabForRepositoryTarget } from "@/features/projects/lib/repositoryDeepLinkTarget"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { Tabs, TabsContent } from "@/shared/ui/tabs"; @@ -127,6 +128,7 @@ export function WorkspaceTabs({ onSelectedTabChange, onBranchChange, onOpenMergeRecoveryTerminal, + onOpenRepositoryRoot, onOpenTerminal, snapshot, snapshotError, @@ -134,6 +136,8 @@ export function WorkspaceTabs({ profiles, repoContributors, repoSource, + repositoryPath, + repositoryRef, sourceControls, terminalTitle, viewerGitIdentity, @@ -164,6 +168,7 @@ export function WorkspaceTabs({ onSelectedTabChange?: (tab: string) => void; onBranchChange: (branch: string | null) => void; onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal; + onOpenRepositoryRoot: () => void; onOpenTerminal?: () => void; snapshot: ProjectRepoSnapshot | null | undefined; snapshotError: unknown; @@ -171,6 +176,8 @@ export function WorkspaceTabs({ profiles?: UserProfileLookup; repoContributors: ProjectRepoContributor[]; repoSource: "remote" | "local"; + repositoryPath?: string; + repositoryRef?: string; /** Branch picker + remote/local toggle for the Code tab header. */ sourceControls?: RepoSourceHeaderControls; terminalTitle?: string; @@ -196,7 +203,9 @@ export function WorkspaceTabs({ (pullRequest) => pullRequest.id === selectedPullRequestId, ) ?? null; const isPullRequestSelected = Boolean(selectedPullRequest); - const [selectedTab, setSelectedTab] = React.useState("overview"); + const [selectedTab, setSelectedTab] = React.useState(() => + projectTabForRepositoryTarget(repositoryPath), + ); const [pullRequestCommentTarget, setPullRequestCommentTarget] = React.useState<{ anchor: ProjectPullRequestCommentAnchor; @@ -206,6 +215,10 @@ export function WorkspaceTabs({ const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); + React.useEffect(() => { + setSelectedTab(projectTabForRepositoryTarget(repositoryPath)); + }, [repositoryPath]); + React.useEffect(() => { onSelectedTabChange?.(selectedTab); }, [onSelectedTabChange, selectedTab]); @@ -468,9 +481,12 @@ export function WorkspaceTabs({ fallbackAuthorPubkey={project.owner} files={files} isLoading={displayedSnapshotLoading} + onOpenRepositoryRoot={onOpenRepositoryRoot} profiles={profiles} snapshot={displayedSnapshot} sourceControls={sourceControls} + targetPath={repositoryPath} + targetRef={repositoryRef} /> diff --git a/desktop/src/features/projects/ui/projectDetailHelpers.test.mjs b/desktop/src/features/projects/ui/projectDetailHelpers.test.mjs new file mode 100644 index 0000000000..283e95abcd --- /dev/null +++ b/desktop/src/features/projects/ui/projectDetailHelpers.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { effectiveProjectRepoSource } from "./projectDetailHelpers.ts"; + +test("repository deep links synchronously force the remote snapshot", () => { + assert.equal(effectiveProjectRepoSource("local", "README.md"), "remote"); + assert.equal(effectiveProjectRepoSource("remote", "README.md"), "remote"); +}); + +test("ordinary project routes preserve the selected repository source", () => { + assert.equal(effectiveProjectRepoSource("local", undefined), "local"); + assert.equal(effectiveProjectRepoSource("remote", undefined), "remote"); +}); diff --git a/desktop/src/features/projects/ui/projectDetailHelpers.ts b/desktop/src/features/projects/ui/projectDetailHelpers.ts index 3552e0db19..68f5f6e4e5 100644 --- a/desktop/src/features/projects/ui/projectDetailHelpers.ts +++ b/desktop/src/features/projects/ui/projectDetailHelpers.ts @@ -1,6 +1,13 @@ import type { Project, ProjectRepoSnapshot } from "@/features/projects/hooks"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function effectiveProjectRepoSource( + selectedSource: "remote" | "local", + repositoryPath: string | undefined, +): "remote" | "local" { + return repositoryPath ? "remote" : selectedSource; +} + export const PROJECT_TAB_CRUMB_LABELS: Record = { files: "Files", activity: "Commits", diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts index db5f31c458..8d487faf3b 100644 --- a/desktop/src/shared/api/projectGit.ts +++ b/desktop/src/shared/api/projectGit.ts @@ -161,6 +161,7 @@ export async function getProjectRepoSnapshot(input: { baseBranch?: string | null; targetRef?: string | null; targetCommit?: string | null; + targetPath?: string | null; }): Promise { const snapshot = await invokeTauri( "get_project_repo_snapshot", @@ -170,6 +171,7 @@ export async function getProjectRepoSnapshot(input: { baseBranch: input.baseBranch ?? null, targetRef: input.targetRef ?? null, targetCommit: input.targetCommit ?? null, + targetPath: input.targetPath ?? null, }, ); return fromRawProjectRepoSnapshot(snapshot); diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index aa3e02984d..402a26982f 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -531,18 +531,11 @@ test("rehypeImageGallery: leaves a single trailing image in the text flow", () = import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import ReactMarkdown from "react-markdown"; -import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; +import { messageLinkUrlTransform } from "./markdown/utils.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; -function messageLinkUrlTransform(value, key) { - if (key === "href" && isMessageLink(value)) { - return value; - } - return defaultUrlTransform(value); -} - function renderMarkdown(content) { return renderToStaticMarkup( React.createElement( @@ -573,6 +566,13 @@ test("messageLinkUrlTransform: preserves buzz://message href with thread", () => assert.match(html, /href="buzz:\/\/message\?[^"]*thread=t1"/); }); +test("messageLinkUrlTransform: preserves buzz://repo href", () => { + const html = renderMarkdown( + "[plan](buzz://repo?owner=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&repo=buzz&ref=main&path=PLANS%2FPLAN.md)", + ); + assert.match(html, /href="buzz:\/\/repo\?[^"]*path=PLANS%2FPLAN\.md"/); +}); + test("messageLinkUrlTransform: still strips javascript: scheme", () => { const html = renderMarkdown("[xss](javascript:alert(1))"); // defaultUrlTransform replaces unsafe schemes with the empty string. diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..972fb409be 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -19,6 +19,12 @@ import { resolveMessageLinkRenderTarget, type ParsedMessageLink, } from "@/features/messages/lib/messageLink"; +import { + isRepoFileLink, + parseRepoFileLink, + repoFileLinkNavigation, + type ParsedRepoFileLink, +} from "@/features/projects/lib/repoFileLink"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; @@ -1287,12 +1293,14 @@ function ExternalLinkAnchor({ href, isLinearLink, label, + onOpen, }: { anchorProps: React.ComponentPropsWithoutRef<"a">; children: React.ReactNode; href: string | undefined; isLinearLink: boolean; label: string; + onOpen?: () => void; }) { const [menu, setMenu] = React.useState(null); const closeMenu = React.useCallback(() => setMenu(null), []); @@ -1306,13 +1314,21 @@ function ExternalLinkAnchor({ isLinearLink ? "linear-link" : "text-primary hover:text-primary/80", )} href={href} + onClick={ + onOpen + ? (event) => { + event.preventDefault(); + onOpen(); + } + : undefined + } onContextMenuCapture={(event) => { if (!href) return; event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY }); }} - rel="noreferrer" - target="_blank" + rel={onOpen ? undefined : "noreferrer"} + target={onOpen ? undefined : "_blank"} > {children} @@ -1331,6 +1347,10 @@ function ExternalLinkAnchor({ label: "Open link", onSelect: () => { closeMenu(); + if (onOpen) { + onOpen(); + return; + } void openUrl(href).catch(() => { toast.error("Failed to open link"); }); @@ -1367,6 +1387,7 @@ function createMarkdownComponents( channels, imetaByUrl, onOpenMessageLink, + onOpenRepoFileLink, onImportSnapshotFromUrl, snapshotSharedBy, } = useMarkdownRuntime(); @@ -1465,6 +1486,24 @@ function createMarkdownComponents( // anchor (renders as a normal external link). } + if (href && isRepoFileLink(href)) { + const parsed = parseRepoFileLink(href); + return ( + { + if (parsed.ok) onOpenRepoFileLink(parsed.value); + else toast.error("This repository link is invalid."); + }} + > + {children} + + ); + } + const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null; const isLinearLink = supportedLinkPreview?.kind === "linear-issue"; @@ -1799,7 +1838,7 @@ function createMarkdownComponents( * four instances ever exist. Module-stable maps mean cached markdown element * trees (see ./markdown/nodeCache.ts) never embed per-mount closures. */ -const MARKDOWN_COMPONENT_SCHEMA_VERSION = "4"; +const MARKDOWN_COMPONENT_SCHEMA_VERSION = "5"; const markdownComponentsByVariant = new Map(); type MarkdownComponentSet = { components: Components; variant: string }; @@ -1845,7 +1884,7 @@ function MarkdownInner({ }: MarkdownProps) { const { channels: rawChannels } = useChannelNavigation(); const channels = useStableArray(rawChannels); - const { goChannel, goAgents } = useAppNavigation(); + const { goChannel, goAgents, goProject } = useAppNavigation(); const onOpenChannel = React.useCallback( (channelId: string) => { void goChannel(channelId); @@ -1868,6 +1907,13 @@ function MarkdownInner({ }, [goChannel], ); + const onOpenRepoFileLink = React.useCallback( + (link: ParsedRepoFileLink) => { + const target = repoFileLinkNavigation(link); + void goProject(target.projectId, target); + }, + [goProject], + ); const linkPreviews = React.useMemo( () => (interactive ? extractSupportedLinkPreviews(content) : []), [content, interactive], @@ -1884,6 +1930,7 @@ function MarkdownInner({ mentionPubkeysByName, onOpenChannel, onOpenMessageLink, + onOpenRepoFileLink, snapshotSharedBy, onImportSnapshotFromUrl: ( fileBytes: number[], @@ -1901,6 +1948,7 @@ function MarkdownInner({ mentionPubkeysByName, onOpenChannel, onOpenMessageLink, + onOpenRepoFileLink, snapshotSharedBy, goAgents, ], diff --git a/desktop/src/shared/ui/markdown/runtimeContext.ts b/desktop/src/shared/ui/markdown/runtimeContext.ts index a067125a39..acf43f8afa 100644 --- a/desktop/src/shared/ui/markdown/runtimeContext.ts +++ b/desktop/src/shared/ui/markdown/runtimeContext.ts @@ -16,6 +16,7 @@ const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = { channels: [], onOpenChannel: () => {}, onOpenMessageLink: () => {}, + onOpenRepoFileLink: () => {}, }; export const MarkdownRuntimeContext = React.createContext( diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 63551024d2..f3449812db 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -1,4 +1,5 @@ import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; +import type { ParsedRepoFileLink } from "@/features/projects/lib/repoFileLink"; import type { Channel } from "@/shared/api/types"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { VideoReviewContext } from "../VideoPlayer"; @@ -32,6 +33,7 @@ export type MarkdownRuntime = { mentionPubkeysByName?: Record; onOpenChannel: (channelId: string) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; + onOpenRepoFileLink: (link: ParsedRepoFileLink) => void; /** Display name of the message author sharing an agent snapshot. */ snapshotSharedBy?: string; /** diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index db9629921e..27f40edbac 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; import { isMessageLink } from "@/features/messages/lib/messageLink"; +import { isRepoFileLink } from "@/features/projects/lib/repoFileLink"; export function useStableArray(arr: T[]): T[] { const ref = React.useRef(arr); @@ -166,13 +167,13 @@ export function isInsideHiddenSpoiler(element: Element): boolean { } /** - * `urlTransform` for `` that preserves `buzz://message?…` - * links. The default transform strips unknown schemes (returns `""`) before + * `urlTransform` for `` that preserves supported in-app + * `buzz://message?…` and `buzz://repo?…` links. The default transform strips unknown schemes (returns `""`) before * the `a` component override can see them, which would break copy → paste → * click end-to-end. Everything else delegates to `defaultUrlTransform`. */ export function messageLinkUrlTransform(value: string, key: string): string { - if (key === "href" && isMessageLink(value)) { + if (key === "href" && (isMessageLink(value) || isRepoFileLink(value))) { return value; } return defaultUrlTransform(value); From 6f33e42e15b93880a6ceec7c7fc141e5c9178301 Mon Sep 17 00:00:00 2001 From: kyinhub Date: Sun, 26 Jul 2026 08:38:37 -0700 Subject: [PATCH 2/2] fix(desktop): refresh linked file after snapshot updates --- .../features/projects/lib/repositoryDeepLinkTarget.test.mjs | 6 +++--- desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs b/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs index 5670570ac4..9a00701d1a 100644 --- a/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs +++ b/desktop/src/features/projects/lib/repositoryDeepLinkTarget.test.mjs @@ -88,11 +88,11 @@ test("resolved targets retry only for a changed repository tree", () => { ); }); -test("repository target resolution is re-armed when the snapshot file set changes", () => { +test("repository target resolution is re-armed when snapshot contents change", () => { const targetKey = "main\0GUIDES/RUNBOOK.md"; assert.notEqual( - repositoryTargetResultKey(targetKey, "README.md"), - repositoryTargetResultKey(targetKey, "README.md\0GUIDES/RUNBOOK.md"), + repositoryTargetResultKey(targetKey, "commit-a\0README.md"), + repositoryTargetResultKey(targetKey, "commit-b\0README.md"), ); }); diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index fcf97a04bf..cf2819d076 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -708,8 +708,9 @@ export function RepositoryFilesPanel({ const targetAttemptRef = React.useRef(null); const targetKey = targetPath ? `${targetRef ?? ""}\u0000${targetPath}` : null; + const targetSnapshotKey = `${latestCommit?.hash ?? ""}\u0000${filesKey}`; const targetResolutionKey = targetKey - ? repositoryTargetResultKey(targetKey, filesKey) + ? repositoryTargetResultKey(targetKey, targetSnapshotKey) : null; React.useEffect(() => { if (!targetResolutionKey) {