From 2dd9e44c0914a05cf86ec1e99002ff1205030590 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 10 Jul 2026 15:07:25 +0200 Subject: [PATCH 01/10] feat(desktop): projects v3 UI polish and repo sync push/pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview: primary-tinted contribution heatmap with per-dot legend tooltips and flush-left month labels, pill filter toolbar (reordered, content-aligned), larger rounded project/repo icons, per-segment activity bar tooltips. PR detail: full-height meta rail (two-column card layout), title-first header with author attribution line. Code tab: sync-aware Remote/Local pill toggle — a small round push or pull button appears inside the toggle when the local checkout is ahead of or behind the remote, backed by a new fast-forward-only pull_project_local_repository Tauri command and can_pull sync status, with 60s polling + focus refetch. Sync hooks split into repoSyncHooks.ts to keep hooks.ts under the file-size ceiling. --- desktop/src-tauri/src/commands/project_git.rs | 77 ++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src/features/projects/hooks.ts | 59 ----------- .../src/features/projects/repoSyncHooks.ts | 100 ++++++++++++++++++ .../projects/ui/ProjectDetailScreen.tsx | 57 +++++++++- .../projects/ui/ProjectPullRequestsPanel.tsx | 26 ++--- .../projects/ui/ProjectRepositorySource.tsx | 82 +++++++++----- .../projects/ui/ProjectWorkspaceTabs.tsx | 56 +++++----- .../projects/ui/ProjectsContributionGraph.tsx | 53 ++++++---- .../projects/ui/ProjectsOverviewPanel.tsx | 6 +- .../features/projects/ui/ProjectsToolbar.tsx | 12 +-- .../src/features/projects/ui/ProjectsView.tsx | 63 ++++++----- desktop/src/shared/api/projectGit.ts | 31 ++++++ desktop/src/shared/api/types.ts | 7 ++ desktop/src/testing/e2eBridge.ts | 7 ++ 15 files changed, 440 insertions(+), 197 deletions(-) create mode 100644 desktop/src/features/projects/repoSyncHooks.ts diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index c4224f80d4..9616a3cfe3 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -63,6 +63,8 @@ pub struct ProjectRepoSyncStatusInfo { pub has_untracked_files: bool, pub can_push: bool, pub push_block_reason: Option, + pub can_pull: bool, + pub pull_block_reason: Option, } #[derive(Serialize)] pub struct ProjectRepoPushResult { @@ -70,6 +72,11 @@ pub struct ProjectRepoPushResult { pub message: String, } #[derive(Serialize)] +pub struct ProjectRepoPullResult { + pub pulled: bool, + pub message: String, +} +#[derive(Serialize)] pub struct GitIdentityInfo { pub name: Option, pub email: Option, @@ -568,6 +575,27 @@ fn compare_local_remote_status( None }; + // Pulling is a fast-forward only merge of origin/ into the + // current checkout, so it is blocked whenever that would not apply + // cleanly (diverged history, dirty worktree, branch mismatch). + let pull_block_reason = if local_head.is_none() { + Some("No local commits yet — clone instead of pulling.".to_string()) + } else if remote_head.is_none() { + Some("Remote branch not found.".to_string()) + } else if behind_count == 0 { + Some("Local branch is up to date.".to_string()) + } else if local_branch.as_deref() != Some(branch.as_str()) { + Some(format!( + "Local checkout is on a different branch than {branch}." + )) + } else if has_uncommitted_changes { + Some("Commit or stash local changes before pulling.".to_string()) + } else if ahead_count > 0 { + Some("Local and remote have diverged — reconcile in a terminal.".to_string()) + } else { + None + }; + ProjectRepoSyncStatusInfo { local_path: Some(repo_dir.display().to_string()), local_branch, @@ -582,6 +610,8 @@ fn compare_local_remote_status( has_untracked_files, can_push: push_block_reason.is_none(), push_block_reason, + can_pull: pull_block_reason.is_none(), + pull_block_reason, } } @@ -778,6 +808,8 @@ pub async fn get_project_repo_sync_status( has_untracked_files: false, can_push: false, push_block_reason: Some("No local checkout found.".to_string()), + can_pull: false, + pull_block_reason: Some("No local checkout found.".to_string()), }); }; @@ -834,3 +866,48 @@ pub async fn push_project_local_repository( .await .map_err(|error| format!("repo push task failed: {error}"))? } + +/// Fast-forwards the local checkout to the remote branch head. Refuses to +/// run whenever the sync status reports the pull would not apply cleanly. +#[tauri::command] +pub async fn pull_project_local_repository( + repos_dir: Option, + project_dtag: String, + clone_url: String, + default_branch: Option, + state: State<'_, AppState>, +) -> Result { + validate_clone_url(&clone_url)?; + let auth = build_git_auth_config(&state)?; + + tauri::async_runtime::spawn_blocking(move || { + let Some(repo_dir) = + find_local_repo_dir(repos_dir.as_deref(), &project_dtag, Some(&clone_url))? + else { + return Err("No local checkout found.".to_string()); + }; + let status = + compare_local_remote_status(&repo_dir, &clone_url, default_branch.as_deref(), &auth); + if !status.can_pull { + return Err(status + .pull_block_reason + .unwrap_or_else(|| "Local checkout cannot be pulled.".to_string())); + } + let branch = status + .remote_branch + .as_deref() + .ok_or_else(|| "No branch selected for pull.".to_string())?; + run_git( + &["pull", "--ff-only", "origin", branch], + Some(&repo_dir), + &auth, + )?; + + Ok(ProjectRepoPullResult { + pulled: true, + message: format!("Pulled {branch} from remote."), + }) + }) + .await + .map_err(|error| format!("repo pull task failed: {error}"))? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1881fbed7e..e3e8d76239 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -567,6 +567,7 @@ pub fn run() { get_project_repo_sync_status, list_project_local_repositories, push_project_local_repository, + pull_project_local_repository, open_project_terminal, search_users, get_presence, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a3ce512fb5..c67c8cf316 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -7,11 +7,9 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import { getProjectLocalRepoDiff, getProjectRepoDiff, - getProjectRepoSyncStatus, getProjectLocalRepoSnapshot, getProjectRepoSnapshot, listProjectLocalRepositories, - pushProjectLocalRepository, } from "@/shared/api/projectGit"; import { KIND_DELETION, @@ -740,36 +738,6 @@ export function useProjectLocalRepositoriesQuery(reposDir?: string | null) { }); } -export function useProjectRepoSyncStatusQuery( - project: Project | null | undefined, - reposDir?: string | null, - branchName?: string | null, -) { - const selectedBranch = branchName ?? project?.defaultBranch ?? null; - - return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), - queryKey: [ - "project", - project?.id ?? "none", - "repo-sync-status", - reposDir ?? "default", - selectedBranch ?? "default", - ], - queryFn: () => { - if (!project?.cloneUrls[0]) throw new Error("No project selected."); - return getProjectRepoSyncStatus({ - reposDir, - projectDtag: project.dtag, - cloneUrl: project.cloneUrls[0], - defaultBranch: selectedBranch, - }); - }, - staleTime: 10_000, - retry: 1, - }); -} - export function useProjectIssuesQuery(project: Project | null | undefined) { return useQuery({ enabled: Boolean(project), @@ -950,30 +918,3 @@ export function useDeleteProjectMutation() { }, }); } - -export function usePushProjectLocalRepositoryMutation( - project: Project | null | undefined, - reposDir?: string | null, - branchName?: string | null, -) { - const queryClient = useQueryClient(); - const selectedBranch = branchName ?? project?.defaultBranch ?? null; - - return useMutation({ - mutationFn: () => { - if (!project?.cloneUrls[0]) throw new Error("No project selected."); - return pushProjectLocalRepository({ - reposDir, - projectDtag: project.dtag, - cloneUrl: project.cloneUrls[0], - defaultBranch: selectedBranch, - }); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ["project", project?.id ?? "none"], - }); - void queryClient.invalidateQueries({ queryKey: ["projects"] }); - }, - }); -} diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts new file mode 100644 index 0000000000..6cfe6d9be5 --- /dev/null +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -0,0 +1,100 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + getProjectRepoSyncStatus, + pullProjectLocalRepository, + pushProjectLocalRepository, +} from "@/shared/api/projectGit"; +import type { Project } from "@/features/projects/hooks"; + +/** Local-vs-remote git sync status for a project checkout (ahead/behind + * counts, push/pull availability). Polls gently — each check runs a + * `git fetch` — and refetches on focus to catch the common "committed in + * a terminal, switched back to the app" flow. */ +export function useProjectRepoSyncStatusQuery( + project: Project | null | undefined, + reposDir?: string | null, + branchName?: string | null, +) { + const selectedBranch = branchName ?? project?.defaultBranch ?? null; + + return useQuery({ + enabled: Boolean(project?.cloneUrls[0]), + queryKey: [ + "project", + project?.id ?? "none", + "repo-sync-status", + reposDir ?? "default", + selectedBranch ?? "default", + ], + queryFn: () => { + if (!project?.cloneUrls[0]) throw new Error("No project selected."); + return getProjectRepoSyncStatus({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0], + defaultBranch: selectedBranch, + }); + }, + staleTime: 10_000, + refetchInterval: 60_000, + refetchOnWindowFocus: true, + retry: 1, + }); +} + +/** Pushes local commits to the project remote. */ +export function usePushProjectLocalRepositoryMutation( + project: Project | null | undefined, + reposDir?: string | null, + branchName?: string | null, +) { + const queryClient = useQueryClient(); + const selectedBranch = branchName ?? project?.defaultBranch ?? null; + + return useMutation({ + mutationFn: () => { + if (!project?.cloneUrls[0]) throw new Error("No project selected."); + return pushProjectLocalRepository({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0], + defaultBranch: selectedBranch, + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none"], + }); + void queryClient.invalidateQueries({ queryKey: ["projects"] }); + }, + }); +} + +/** Fast-forwards the local checkout to the remote branch head. */ +export function usePullProjectLocalRepositoryMutation( + project: Project | null | undefined, + reposDir?: string | null, + branchName?: string | null, +) { + const queryClient = useQueryClient(); + const selectedBranch = branchName ?? project?.defaultBranch ?? null; + + return useMutation({ + mutationFn: () => { + if (!project?.cloneUrls[0]) throw new Error("No project selected."); + return pullProjectLocalRepository({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0], + defaultBranch: selectedBranch, + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none"], + }); + void queryClient.invalidateQueries({ queryKey: ["projects"] }); + }, + }); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index fbff91fe91..364e76417b 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -18,12 +18,15 @@ import { useProjectLocalRepoDiffQuery, useProjectLocalRepoSnapshotQuery, useProjectRepoDiffQuery, - useProjectRepoSyncStatusQuery, - usePushProjectLocalRepositoryMutation, useProjectPullRequestsQuery, useProjectRepoSnapshotQuery, useRepoStateQuery, } from "@/features/projects/hooks"; +import { + useProjectRepoSyncStatusQuery, + usePullProjectLocalRepositoryMutation, + usePushProjectLocalRepositoryMutation, +} from "@/features/projects/repoSyncHooks"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup, @@ -65,6 +68,16 @@ import { } from "./useOpenProjectTerminal"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; +/** Tooltip for the push/pull sync buttons, e.g. "Pull 2 remote commits". */ +function pushPullTitle( + verb: "Push" | "Pull", + count: number | undefined, + side: "local" | "remote", +) { + if (!count) return `${verb} ${side} commits`; + return `${verb} ${count} ${side} ${count === 1 ? "commit" : "commits"}`; +} + function projectPeople(project: Project) { return [ ...new Set( @@ -235,6 +248,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { activeWorkspace?.reposDir, activeBranch, ); + const pullLocalRepoMutation = usePullProjectLocalRepositoryMutation( + project, + activeWorkspace?.reposDir, + activeBranch, + ); const hasLocalCheckout = Boolean( localRepoSnapshotQuery.data || repoSyncStatusQuery.data?.localPath, ); @@ -273,7 +291,18 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { pushLocalRepoMutation.isPending || !repoSyncStatusQuery.data?.canPush, pushPending: pushLocalRepoMutation.isPending, pushTitle: - repoSyncStatusQuery.data?.pushBlockReason ?? "Push local commits", + repoSyncStatusQuery.data?.pushBlockReason ?? + pushPullTitle("Push", repoSyncStatusQuery.data?.aheadCount, "local"), + canPull: repoSyncStatusQuery.data?.canPull ?? false, + onPull: () => { + void handlePullLocalRepo(); + }, + pullDisabled: + pullLocalRepoMutation.isPending || !repoSyncStatusQuery.data?.canPull, + pullPending: pullLocalRepoMutation.isPending, + pullTitle: + repoSyncStatusQuery.data?.pullBlockReason ?? + pushPullTitle("Pull", repoSyncStatusQuery.data?.behindCount, "remote"), }; const projectPending = projectQuery.isPending; React.useEffect(() => { @@ -399,6 +428,28 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { repoStateQuery, repoSyncStatusQuery, ]); + const handlePullLocalRepo = React.useCallback(async () => { + try { + const result = await pullLocalRepoMutation.mutateAsync(); + toast.success(result.message); + await Promise.all([ + repoSnapshotQuery.refetch(), + localRepoSnapshotQuery.refetch(), + repoSyncStatusQuery.refetch(), + repoStateQuery.refetch(), + ]); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to pull repository", + ); + } + }, [ + localRepoSnapshotQuery, + pullLocalRepoMutation, + repoSnapshotQuery, + repoStateQuery, + repoSyncStatusQuery, + ]); const openTerminal = useOpenProjectTerminal(activeWorkspace?.reposDir); const handleOpenTerminal = React.useCallback(() => { diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index 1cac9a87a8..b2e70952fb 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -538,32 +538,20 @@ export function PullRequestDetailHeader({ profiles?: UserProfileLookup; pullRequest: ProjectPullRequest; }) { - const authorProfile = profileForPubkey(pullRequest.author, profiles); const authorLabel = labelForPubkey(pullRequest.author, profiles); return ( -
+
+

+ {pullRequest.title}{" "} + + #{pullRequest.id.slice(0, 8)} + +

Pull request from {authorLabel}

-
- -

- {pullRequest.title}{" "} - - #{pullRequest.id.slice(0, 8)} - -

-
); } diff --git a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx index 2d01ef402d..5ff43513e8 100644 --- a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx @@ -1,4 +1,9 @@ -import { ChevronDown, GitBranch, UploadCloud } from "lucide-react"; +import { + ChevronDown, + DownloadCloud, + GitBranch, + UploadCloud, +} from "lucide-react"; import { Button } from "@/shared/ui/button"; import { @@ -72,24 +77,49 @@ export type RepoSourceHeaderControls = { localDisabled: boolean; localLabel: string; remoteLabel: string; - /** Push of local commits, shown only when the local source can push. */ + /** Push of local commits, shown when the local checkout is ahead. */ canPush?: boolean; onPush?: () => void; pushDisabled?: boolean; pushPending?: boolean; pushTitle?: string; + /** Fast-forward pull, shown when the local checkout is behind. */ + canPull?: boolean; + onPull?: () => void; + pullDisabled?: boolean; + pullPending?: boolean; + pullTitle?: string; }; -/** Small pill toggle between the remote and local repository source. */ +/** Small pill toggle between the remote and local repository source. + * When the local checkout is out of sync with the remote, a rounded + * push or pull button appears next to the toggle. */ export function RepoSourceToggle({ controls, }: { controls: RepoSourceHeaderControls; }) { - const showPush = - controls.source === "local" && controls.canPush && controls.onPush; + const showPush = controls.canPush && controls.onPush; + const showPull = controls.canPull && controls.onPull; return ( -
+
+ {showPull ? ( + + ) : null} {showPush ? ( ) : null} -
- - -
+ +
); } diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 24a443c198..4d35a4a31c 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -198,20 +198,22 @@ export function WorkspaceTabs({ {selectedPullRequest ? (
- -
- -
- {(["conversation", "commits", "checks"] as const).map((mode) => ( - -
-
+ {/* Two full-height columns: the meta rail runs all the way to the + top of the card, alongside the header and tabs. */} +
+
+ +
+ +
+ {(["conversation", "commits", "checks"] as const).map((mode) => ( + -
- + ))} + + -
- - ))} - - +
+ - +
) : null} diff --git a/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx b/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx index fbaa1193d3..f74feb32c4 100644 --- a/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx +++ b/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx @@ -4,15 +4,24 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const WEEK_COUNT = 26; const DAYS_PER_WEEK = 7; -// Intensity ramp shared by the cells and the "Less … More" legend. -// Grayscale on the theme's foreground token: the most active cells are -// near-black in light mode and near-white in dark mode. +// Intensity ramp shared by the cells and the legend dots. +// Tints of the theme's primary token: the most active cells are fully +// saturated primary, quieter days fade toward the muted background. const LEVEL_CLASSES = [ "bg-muted/60 dark:bg-muted/40", - "bg-foreground/25", - "bg-foreground/50", - "bg-foreground/75", - "bg-foreground", + "bg-primary/25", + "bg-primary/50", + "bg-primary/75", + "bg-primary", +]; + +// Descriptors matching the thresholds in `levelFor`. +const LEVEL_LABELS = [ + "No activity", + "1–2 events", + "3–5 events", + "6–9 events", + "10+ events", ]; function dayKeyOf(date: Date) { @@ -46,13 +55,17 @@ function buildWeeks(today: Date) { ); } +// Minimum columns between labels so adjacent months never overlap. +const MIN_LABEL_GAP = 3; + function monthLabels(weeks: Date[][]) { + let lastLabeledIndex = -MIN_LABEL_GAP; return weeks.map((week, index) => { - if (index === 0) return ""; - const month = week[0].getMonth(); - return month !== weeks[index - 1][0].getMonth() - ? week[0].toLocaleDateString(undefined, { month: "short" }) - : ""; + const isNewMonth = + index === 0 || week[0].getMonth() !== weeks[index - 1][0].getMonth(); + if (!isNewMonth || index - lastLabeledIndex < MIN_LABEL_GAP) return ""; + lastLabeledIndex = index; + return week[0].toLocaleDateString(undefined, { month: "short" }); }); } @@ -136,15 +149,15 @@ export function ProjectsContributionGraph({ {totalContributions} {totalContributions === 1 ? "event" : "events"}{" "} in the last 6 months

-
- Less - {LEVEL_CLASSES.map((levelClass) => ( - +
+ {LEVEL_CLASSES.map((levelClass, level) => ( + + + + + {LEVEL_LABELS[level]} + ))} - More
diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 8feef267f8..9e18cbb037 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -223,9 +223,9 @@ export function ProjectsOverviewPanel({ return (
-
- -
+
+ +

{relayName} Projects

diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 81bc5f545a..545df01c3c 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -19,7 +19,7 @@ export function ProjectsViewModeToggle({ onViewModeChange: (viewMode: ProjectsViewMode) => void; }) { return ( -
+
Project layout