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 1ba9400822..c79dca586b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -675,6 +675,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/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ae2adc31e6..f928970610 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -94,6 +94,7 @@ export function useAppNavigation() { ( projectId: string, behavior?: NavigationBehavior & { + commitHash?: string; pullRequestId?: string; issueId?: string; }, @@ -105,6 +106,9 @@ export function useAppNavigation() { projectId, }, search: { + ...(behavior?.commitHash + ? { commitHash: behavior.commitHash } + : {}), ...(behavior?.pullRequestId ? { pullRequestId: behavior.pullRequestId } : {}), diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 0dd3b23385..3ce58efa8c 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -12,6 +12,8 @@ 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 @@ -23,11 +25,12 @@ export const Route = createFileRoute("/projects/$projectId")({ function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { pullRequestId, issueId } = Route.useSearch(); + const { commitHash, pullRequestId, issueId } = Route.useSearch(); return ( }> { +export async function fetchProjects(): Promise { const [events, deletionEvents] = await Promise.all([ relayClient.fetchEvents({ kinds: [KIND_REPO_ANNOUNCEMENT], @@ -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/lib/projectAgentConversationStorage.ts b/desktop/src/features/projects/lib/projectAgentConversationStorage.ts new file mode 100644 index 0000000000..bc27a12856 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentConversationStorage.ts @@ -0,0 +1,97 @@ +const CONVERSATION_STORAGE_PREFIX = "buzz.projects.agentConversation"; +const CLEARED_AT_STORAGE_PREFIX = "buzz.projects.agentConversationClearedAt"; + +/** Minimal workspace-scoped pointer to the last inline Projects conversation. */ +export type StoredProjectsAgentConversation = { + agentPubkey: string; + channelId: string; + visibleAfter: number; +}; + +function scopedKey(prefix: string, workspaceId: string) { + return `${prefix}.${encodeURIComponent(workspaceId)}`; +} + +/** Reads the last inline Projects conversation without persisting its content. */ +export function readStoredProjectsAgentConversation( + workspaceId: string | null, +): StoredProjectsAgentConversation | null { + if (!workspaceId) return null; + try { + const raw = globalThis.localStorage?.getItem( + scopedKey(CONVERSATION_STORAGE_PREFIX, workspaceId), + ); + if (!raw) return null; + const value = JSON.parse(raw) as Partial; + if ( + typeof value.agentPubkey !== "string" || + value.agentPubkey.length === 0 || + typeof value.channelId !== "string" || + value.channelId.length === 0 || + typeof value.visibleAfter !== "number" || + !Number.isFinite(value.visibleAfter) || + value.visibleAfter < 0 + ) { + return null; + } + return { + agentPubkey: value.agentPubkey, + channelId: value.channelId, + visibleAfter: value.visibleAfter, + }; + } catch { + return null; + } +} + +/** Saves only the channel pointer needed to restore the Projects conversation. */ +export function writeStoredProjectsAgentConversation( + workspaceId: string | null, + conversation: StoredProjectsAgentConversation, +) { + if (!workspaceId) return; + try { + globalThis.localStorage?.setItem( + scopedKey(CONVERSATION_STORAGE_PREFIX, workspaceId), + JSON.stringify(conversation), + ); + } catch { + // Persistence is best-effort; the in-memory conversation remains usable. + } +} + +/** Reads the cutoff used to prevent cleared history from being restored. */ +export function readProjectsAgentConversationClearedAt( + workspaceId: string | null, +) { + if (!workspaceId) return 0; + try { + const value = Number( + globalThis.localStorage?.getItem( + scopedKey(CLEARED_AT_STORAGE_PREFIX, workspaceId), + ), + ); + return Number.isFinite(value) && value > 0 ? value : 0; + } catch { + return 0; + } +} + +/** Clears the saved pointer and records when prior history was dismissed. */ +export function markProjectsAgentConversationCleared( + workspaceId: string | null, + clearedAt: number, +) { + if (!workspaceId) return; + try { + globalThis.localStorage?.removeItem( + scopedKey(CONVERSATION_STORAGE_PREFIX, workspaceId), + ); + globalThis.localStorage?.setItem( + scopedKey(CLEARED_AT_STORAGE_PREFIX, workspaceId), + String(clearedAt), + ); + } catch { + // Persistence is best-effort; the current page still clears immediately. + } +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 11b4c4317b..a7dfbcc894 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -90,6 +90,34 @@ export function pluralize( return `${count} ${count === 1 ? singular : plural}`; } +/** + * Flatten Markdown source into a clean single-line-friendly plain-text string + * for compact previews (activity feed, list excerpts). This is intentionally + * lightweight — it strips the common syntax that would otherwise leak as raw + * characters (`##`, `**`, backticks, links) rather than rendering rich + * Markdown, which is inappropriate inside a clamped one/two-line preview. + */ +export function markdownToPlainText(input: string): string { + return ( + input + // Fenced code blocks: drop the fences, keep the inner code text. + .replace(/```[^\n]*\n?/g, "") + .replace(/```/g, "") + // Images `![alt](url)` -> alt text. + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + // Links `[text](url)` -> text. + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + // Line-leading markers: headings, blockquotes, list bullets/numbers. + .replace(/^[ \t]{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])[ \t]+/gm, "") + // Emphasis: bold/italic/strikethrough — keep the inner text. + .replace(/(\*\*|__)(.+?)\1/g, "$2") + .replace(/([*_])(.+?)\1/g, "$2") + .replace(/~~(.+?)~~/g, "$1") + // Inline code — keep the inner text. + .replace(/`([^`]+)`/g, "$1") + ); +} + export function formatCreatedDate(createdAt: number) { return new Date(createdAt * 1_000).toLocaleDateString(undefined, { month: "short", 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/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx new file mode 100644 index 0000000000..c5e6c2670c --- /dev/null +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -0,0 +1,258 @@ +import * as React from "react"; + +import type { CreateProjectInput } from "@/features/projects/useCreateProject"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +const CREATE_FIELD_SHELL_CLASS = + "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const CREATE_FIELD_CONTROL_CLASS = + "border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; +const CREATE_LABEL_OPTIONAL_CLASS = + "ml-1 text-xs font-normal text-muted-foreground/50"; + +type CreateProjectDialogProps = { + isCreating: boolean; + onCreate: (input: CreateProjectInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +/** Modal for publishing a new project (NIP-34 repo announcement). */ +export function CreateProjectDialog({ + isCreating, + onCreate, + onOpenChange, + open, +}: CreateProjectDialogProps) { + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [cloneUrl, setCloneUrl] = React.useState(""); + const [webUrl, setWebUrl] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + const nameInputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + + setName(""); + setDescription(""); + setCloneUrl(""); + setWebUrl(""); + setErrorMessage(null); + + // Small delay to let the dialog animation start before focusing. + const timerId = globalThis.setTimeout(() => { + nameInputRef.current?.focus(); + }, 50); + return () => globalThis.clearTimeout(timerId); + }, [open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const trimmedName = name.trim(); + if (!trimmedName) return; + + setErrorMessage(null); + + try { + await onCreate({ + name: trimmedName, + description: description.trim() || undefined, + cloneUrl: cloneUrl.trim() || undefined, + webUrl: webUrl.trim() || undefined, + }); + + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to create project.", + ); + } + } + + return ( + { + if (!nextOpen && isCreating) return; + onOpenChange(nextOpen); + }} + open={open} + > + + + + } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title="Create a new project" + > +
{ + void handleSubmit(event); + }} + > +
+ +
+ { + setName(event.target.value); + setErrorMessage(null); + }} + placeholder="bee-garden-game" + ref={nameInputRef} + spellCheck={false} + value={name} + /> +
+
+ +
+ +
+