diff --git a/apps/desktop/src/components/ChromeHeader.svelte b/apps/desktop/src/components/ChromeHeader.svelte index 75fce8da16..090ae0ccff 100644 --- a/apps/desktop/src/components/ChromeHeader.svelte +++ b/apps/desktop/src/components/ChromeHeader.svelte @@ -8,8 +8,8 @@ import { ircEnabled } from '$lib/config/uiFeatureFlags'; import { IRC_SERVICE } from '$lib/irc/ircService.svelte'; import { MODE_SERVICE } from '$lib/mode/modeService'; - import { handleAddProjectOutcome } from '$lib/project/project'; import { PROJECTS_SERVICE } from '$lib/project/projectsService'; + import { useAddProject } from '$lib/project/useProjects.svelte'; import { ircPath, projectPath, isWorkspacePath } from '$lib/routes/routes.svelte'; import { UI_STATE } from '$lib/state/uiState.svelte'; import { inject } from '@gitbutler/core/context'; @@ -47,6 +47,8 @@ const singleBranchMode = $derived($settingsStore?.featureFlags.singleBranch ?? false); const backend = inject(BACKEND); + const { addProject } = useAddProject(); + const mode = $derived(modeService.mode({ projectId })); const currentMode = $derived(mode.response); const currentBranchName = $derived.by(() => { @@ -188,14 +190,7 @@ onClick={async () => { newProjectLoading = true; try { - const outcome = await projectsService.addProject(); - if (!outcome) { - // User cancelled the project creation - newProjectLoading = false; - return; - } - - handleAddProjectOutcome(outcome, (project) => goto(projectPath(project.id))); + await addProject(); } finally { newProjectLoading = false; } diff --git a/apps/desktop/src/components/CloneForm.svelte b/apps/desktop/src/components/CloneForm.svelte index 71e8b12cb4..15c016f7d9 100644 --- a/apps/desktop/src/components/CloneForm.svelte +++ b/apps/desktop/src/components/CloneForm.svelte @@ -5,9 +5,7 @@ import { OnboardingEvent, POSTHOG_WRAPPER } from '$lib/analytics/posthog'; import { BACKEND } from '$lib/backend'; import { GIT_SERVICE } from '$lib/git/gitService'; - import { handleAddProjectOutcome } from '$lib/project/project'; - import { PROJECTS_SERVICE } from '$lib/project/projectsService'; - import { projectPath } from '$lib/routes/routes.svelte'; + import { useAddProject } from '$lib/project/useProjects.svelte'; import { parseRemoteUrl } from '$lib/url/gitUrl'; import { inject } from '@gitbutler/core/context'; import { persisted } from '@gitbutler/shared/persisted'; @@ -16,7 +14,6 @@ import * as Sentry from '@sentry/sveltekit'; import { onMount } from 'svelte'; - const projectsService = inject(PROJECTS_SERVICE); const gitService = inject(GIT_SERVICE); const posthog = inject(POSTHOG_WRAPPER); const backend = inject(BACKEND); @@ -62,6 +59,14 @@ return String(error); } + const { addProject } = useAddProject(() => { + posthog.captureOnboarding( + OnboardingEvent.ClonedProjectFailed, + 'Failed to add project after cloning' + ); + throw new Error('Failed to add project after cloning.'); + }); + async function cloneRepository() { loading = true; savedTargetDirPath.set(targetDirPath); @@ -88,16 +93,7 @@ await gitService.cloneRepo(repositoryUrl, targetDir); posthog.captureOnboarding(OnboardingEvent.ClonedProject); - const outcome = await projectsService.addProject(targetDir); - if (!outcome) { - posthog.captureOnboarding( - OnboardingEvent.ClonedProjectFailed, - 'Failed to add project after cloning' - ); - throw new Error('Failed to add project after cloning.'); - } - - handleAddProjectOutcome(outcome, (project) => goto(projectPath(project.id))); + await addProject(targetDir); } catch (e) { Sentry.captureException(e); const errorMessage = getErrorMessage(e); diff --git a/apps/desktop/src/components/FileMenuAction.svelte b/apps/desktop/src/components/FileMenuAction.svelte index 5d0f5fe70f..d553e31386 100644 --- a/apps/desktop/src/components/FileMenuAction.svelte +++ b/apps/desktop/src/components/FileMenuAction.svelte @@ -1,24 +1,19 @@
@@ -48,13 +50,7 @@ onClick={async () => { newProjectLoading = true; try { - const outcome = await projectsService.addProject(); - if (!outcome) { - // User cancelled the project creation - newProjectLoading = false; - return; - } - handleAddProjectOutcome(outcome, (project) => goto(projectPath(project.id))); + await addProject(); } finally { newProjectLoading = false; } diff --git a/apps/desktop/src/components/Welcome.svelte b/apps/desktop/src/components/Welcome.svelte index 00b39cf059..e084a72f01 100644 --- a/apps/desktop/src/components/Welcome.svelte +++ b/apps/desktop/src/components/Welcome.svelte @@ -6,6 +6,7 @@ import { OnboardingEvent, POSTHOG_WRAPPER } from '$lib/analytics/posthog'; import cloneRepoSvg from '$lib/assets/welcome/clone-repo.svg?raw'; import newProjectSvg from '$lib/assets/welcome/new-local-project.svg?raw'; + import { showToast } from '$lib/notifications/toasts'; import { handleAddProjectOutcome } from '$lib/project/project'; import { PROJECTS_SERVICE } from '$lib/project/projectsService'; import { inject } from '@gitbutler/core/context'; @@ -17,23 +18,33 @@ let newProjectLoading = $state(false); let directoryInputElement = $state(); - async function onNewProject() { - newProjectLoading = true; + async function addProject(path: string) { try { - const testDirectoryPath = directoryInputElement?.value; - const outcome = await projectsService.addProject(testDirectoryPath ?? ''); - + const outcome = await projectsService.addProject(path); posthog.captureOnboarding(OnboardingEvent.AddLocalProject); + if (outcome) { - handleAddProjectOutcome(outcome); + handleAddProjectOutcome(outcome, async (path: string) => { + await projectsService.initGitRepository(path); + showToast({ + title: 'Repository Initialized', + message: `Git repository has been successfully initialized at ${path}. Loading project...`, + style: 'info' + }); + await addProject(path); + }); } } catch (e: unknown) { posthog.captureOnboarding(OnboardingEvent.AddLocalProjectFailed, e); - } finally { - newProjectLoading = false; } } + async function onNewProject() { + newProjectLoading = true; + await addProject(directoryInputElement?.value ?? ''); + newProjectLoading = false; + } + async function onCloneProject() { goto('/onboarding/clone'); } diff --git a/apps/desktop/src/lib/project/project.ts b/apps/desktop/src/lib/project/project.ts index 33a1c12871..f04459f1b2 100644 --- a/apps/desktop/src/lib/project/project.ts +++ b/apps/desktop/src/lib/project/project.ts @@ -85,15 +85,19 @@ export type AddProjectOutcome = /** * The error message received */ - subject: string; + subject: { + path: string; + message: string; + }; }; /** * Correctly handle the outcome of an addProject operation by passing the project to the callback or - * showing toasts as necessary. + * showing toasts as necessary.'get this - needs a refactor probably'; */ export function handleAddProjectOutcome( outcome: AddProjectOutcome, + onInitialize: (path: string) => Promise, onAdded?: (project: Project) => void ): true { switch (outcome.type) { @@ -163,9 +167,19 @@ export function handleAddProjectOutcome( case 'notAGitRepository': showToast({ testId: TestId.AddProjectNotAGitRepoModal, - style: 'warning', title: 'Not a Git repository', - message: `Unable to add project: ${outcome.subject}` + message: + 'The selected directory is not a Git repository. Would you like to initialize one?', + style: 'warning', + extraAction: { + testId: TestId.AddProjectNotAGitRepoModalInitializeButton, + label: 'Initialize Repository', + onClick: async (dismiss) => { + const projectPath = outcome.subject.path; + await onInitialize(projectPath); + dismiss(); + } + } }); return true; } diff --git a/apps/desktop/src/lib/project/projectsService.ts b/apps/desktop/src/lib/project/projectsService.ts index ab11e514aa..a598c2c0da 100644 --- a/apps/desktop/src/lib/project/projectsService.ts +++ b/apps/desktop/src/lib/project/projectsService.ts @@ -137,6 +137,10 @@ export class ProjectsService { unsetLastOpenedProject() { this.persistedId.set(undefined); } + + async initGitRepository(path: string): Promise { + return await this.backend.invoke('init_git_repository', { path }); + } } function injectEndpoints(api: ClientState['backendApi']) { diff --git a/apps/desktop/src/lib/project/useProjects.svelte.ts b/apps/desktop/src/lib/project/useProjects.svelte.ts new file mode 100644 index 0000000000..bdb646085d --- /dev/null +++ b/apps/desktop/src/lib/project/useProjects.svelte.ts @@ -0,0 +1,34 @@ +import { goto } from '$app/navigation'; +import { showToast } from '$lib/notifications/toasts'; +import { handleAddProjectOutcome } from '$lib/project/project'; +import { PROJECTS_SERVICE } from '$lib/project/projectsService'; +import { projectPath } from '$lib/routes/routes.svelte'; +import { inject } from '@gitbutler/core/context'; + +export function useAddProject(onMissingOutcome?: () => void) { + const projectsService = inject(PROJECTS_SERVICE); + + async function addProject(path?: string) { + const outcome = await projectsService.addProject(path); + + if (outcome) { + handleAddProjectOutcome( + outcome, + async (path: string) => { + await projectsService.initGitRepository(path); + showToast({ + title: 'Repository Initialized', + message: `Git repository has been successfully initialized at ${path}. Loading project...`, + style: 'info' + }); + await addProject(path); + }, + (project) => goto(projectPath(project.id)) + ); + } else { + onMissingOutcome?.(); + } + } + + return { addProject }; +} diff --git a/crates/but-api/src/commands/projects.rs b/crates/but-api/src/commands/projects.rs index f61728a1cc..67e2d3e888 100644 --- a/crates/but-api/src/commands/projects.rs +++ b/crates/but-api/src/commands/projects.rs @@ -1,4 +1,5 @@ use crate::error::Error; +use anyhow::Context; use but_api_macros::api_cmd; use gitbutler_project::{self as projects, ProjectId}; use std::path::PathBuf; @@ -40,3 +41,14 @@ pub fn get_project( pub fn delete_project(project_id: ProjectId) -> Result<(), Error> { gitbutler_project::delete(project_id).map_err(Into::into) } + +/// Initialize a Git repository at the given path +#[api_cmd] +#[tauri::command(async)] +#[instrument(err(Debug))] +pub fn init_git_repository(path: String) -> Result<(), Error> { + let path: PathBuf = path.into(); + git2::Repository::init(&path) + .with_context(|| format!("Failed to initialize Git repository at {}", path.display()))?; + Ok(()) +} diff --git a/crates/but-server/src/lib.rs b/crates/but-server/src/lib.rs index b28fc8e33f..abff76bcb6 100644 --- a/crates/but-server/src/lib.rs +++ b/crates/but-server/src/lib.rs @@ -233,6 +233,7 @@ async fn handle_command( "add_project" => iprojects::add_project_cmd(request.params), "get_project" => iprojects::get_project_cmd(request.params), "delete_project" => iprojects::delete_project_cmd(request.params), + "init_git_repository" => iprojects::init_git_repository_cmd(request.params), "list_projects" => projects::list_projects(&extra).await, "set_project_active" => { projects::set_project_active(&app, &extra, app_settings_sync, request.params).await diff --git a/crates/gitbutler-project/src/controller.rs b/crates/gitbutler-project/src/controller.rs index efaf77ecaa..5296648b24 100644 --- a/crates/gitbutler-project/src/controller.rs +++ b/crates/gitbutler-project/src/controller.rs @@ -4,7 +4,10 @@ use anyhow::{anyhow, bail, Context, Result}; use gitbutler_error::error; use super::{storage, storage::UpdateRequest, Project, ProjectId}; -use crate::{project::AddProjectOutcome, AuthKey}; +use crate::{ + project::{AddProjectOutcome, NotAGitRepositoryOutcome}, + AuthKey, +}; #[derive(Clone, Debug)] pub(crate) struct Controller { @@ -96,9 +99,15 @@ impl Controller { } } }, - Err(err) => { - return Ok(AddProjectOutcome::NotAGitRepository(err.to_string())); + Err(err @ gix::open::Error::NotARepository { .. }) => { + return Ok(AddProjectOutcome::NotAGitRepository( + NotAGitRepositoryOutcome { + path: path.to_path_buf(), + message: err.to_string(), + }, + )); } + Err(err) => return Err(err.into()), } let id = ProjectId::generate(); diff --git a/crates/gitbutler-project/src/project.rs b/crates/gitbutler-project/src/project.rs index 6a220f68a4..62b33f6775 100644 --- a/crates/gitbutler-project/src/project.rs +++ b/crates/gitbutler-project/src/project.rs @@ -183,6 +183,12 @@ impl Project { } } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NotAGitRepositoryOutcome { + pub path: PathBuf, + pub message: String, +} + #[derive(Debug, Serialize, Deserialize, Clone, strum::Display)] #[serde(rename_all = "camelCase", tag = "type", content = "subject")] pub enum AddProjectOutcome { @@ -194,7 +200,7 @@ pub enum AddProjectOutcome { NonMainWorktree, NoWorkdir, NoDotGitDirectory, - NotAGitRepository(String), + NotAGitRepository(NotAGitRepositoryOutcome), } impl AddProjectOutcome { @@ -229,7 +235,7 @@ impl AddProjectOutcome { Err(anyhow::anyhow!("no .git directory found in repository")) } AddProjectOutcome::NotAGitRepository(msg) => { - Err(anyhow::anyhow!("not a git repository: {}", msg)) + Err(anyhow::anyhow!("not a git repository: {}", msg.message)) } } } diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 6ed36524e6..9d1ac51445 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -224,6 +224,7 @@ fn main() { but_api::projects::get_project, but_api::projects::update_project, but_api::projects::delete_project, + but_api::projects::init_git_repository, projects::list_projects, projects::set_project_active, projects::open_project_in_window, diff --git a/e2e/playwright/tests/addingAProject.spec.ts b/e2e/playwright/tests/addingAProject.spec.ts index 02381d6f6a..d11302a7aa 100644 --- a/e2e/playwright/tests/addingAProject.spec.ts +++ b/e2e/playwright/tests/addingAProject.spec.ts @@ -96,6 +96,12 @@ test('should handle gracefully adding a non-git directory', async ({ page, conte await fileChooser.setFiles(projectPath); await waitForTestId(page, 'add-project-not-a-git-repo-modal'); + // Click it in order to close the select dropdown behind + await clickByTestId(page, 'add-project-not-a-git-repo-modal', true); + + await clickByTestId(page, 'add-project-not-a-git-repo-modal-initialize-button'); + + await waitForTestId(page, 'workspace-view'); }); test('should handle adding a project with extra commit and uncommitted changes on main branch', async ({ diff --git a/packages/ui/src/lib/utils/testIds.ts b/packages/ui/src/lib/utils/testIds.ts index a265f2a8ab..cd64df21cf 100644 --- a/packages/ui/src/lib/utils/testIds.ts +++ b/packages/ui/src/lib/utils/testIds.ts @@ -137,6 +137,7 @@ export enum TestId { AddProjectBareRepoModal = 'add-project-bare-repo-modal', AddProjectNoDotGitDirectoryModal = 'add-project-no-dot-git-directory-modal', AddProjectNotAGitRepoModal = 'add-project-not-a-git-repo-modal', + AddProjectNotAGitRepoModalInitializeButton = 'add-project-not-a-git-repo-modal-initialize-button', ChromeHeaderProjectSelector = 'chrome-header-project-selector', ChromeHeaderProjectSelectorAddLocalProject = 'chrome-header-project-selector-add-local-project', ChromeSideBarProjectSettingsButton = 'chrome-sidebar-project-settings-button',