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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions desktop/src-tauri/src/commands/project_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,20 @@ pub struct ProjectRepoSyncStatusInfo {
pub has_untracked_files: bool,
pub can_push: bool,
pub push_block_reason: Option<String>,
pub can_pull: bool,
pub pull_block_reason: Option<String>,
}
#[derive(Serialize)]
pub struct ProjectRepoPushResult {
pub pushed: bool,
pub message: String,
}
#[derive(Serialize)]
pub struct ProjectRepoPullResult {
pub pulled: bool,
pub message: String,
}
#[derive(Serialize)]
pub struct GitIdentityInfo {
pub name: Option<String>,
pub email: Option<String>,
Expand Down Expand Up @@ -568,6 +575,27 @@ fn compare_local_remote_status(
None
};

// Pulling is a fast-forward only merge of origin/<branch> 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,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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()),
});
};

Expand Down Expand Up @@ -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<String>,
project_dtag: String,
clone_url: String,
default_branch: Option<String>,
state: State<'_, AppState>,
) -> Result<ProjectRepoPullResult, String> {
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}"))?
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function useAppNavigation() {
(
projectId: string,
behavior?: NavigationBehavior & {
commitHash?: string;
pullRequestId?: string;
issueId?: string;
},
Expand All @@ -105,6 +106,9 @@ export function useAppNavigation() {
projectId,
},
search: {
...(behavior?.commitHash
? { commitHash: behavior.commitHash }
: {}),
...(behavior?.pullRequestId
? { pullRequestId: behavior.pullRequestId }
: {}),
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/app/routes/projects.$projectId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const ProjectDetailScreen = React.lazy(async () => {
export const Route = createFileRoute("/projects/$projectId")({
component: ProjectDetailRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
commitHash:
typeof search.commitHash === "string" ? search.commitHash : undefined,
pullRequestId:
typeof search.pullRequestId === "string"
? search.pullRequestId
Expand All @@ -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 (
<React.Suspense fallback={<ViewLoadingFallback kind="projects" />}>
<ProjectDetailScreen
commitHash={commitHash}
issueId={issueId}
projectId={projectId}
pullRequestId={pullRequestId}
Expand Down
63 changes: 2 additions & 61 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,7 +160,7 @@ function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean {
);
}

function eventToProject(event: RelayEvent): Project {
export function eventToProject(event: RelayEvent): Project {
const d = getTag(event, "d") ?? event.id;
const name = getTag(event, "name") || d;
const description = getTag(event, "description") || event.content || "";
Expand Down Expand Up @@ -212,7 +210,7 @@ function dedup(events: RelayEvent[]): RelayEvent[] {
return [...best.values()];
}

async function fetchProjects(): Promise<Project[]> {
export async function fetchProjects(): Promise<Project[]> {
const [events, deletionEvents] = await Promise.all([
relayClient.fetchEvents({
kinds: [KIND_REPO_ANNOUNCEMENT],
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"] });
},
});
}
Original file line number Diff line number Diff line change
@@ -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<StoredProjectsAgentConversation>;
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.
}
}
28 changes: 28 additions & 0 deletions desktop/src/features/projects/lib/projectsViewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading