diff --git a/src/main/core/projects/controller.ts b/src/main/core/projects/controller.ts index 80ebe9ace..7a4f52f53 100644 --- a/src/main/core/projects/controller.ts +++ b/src/main/core/projects/controller.ts @@ -10,6 +10,7 @@ import { getProjectBootstrapStatus } from './operations/getProjectBootstrapStatu import { getLocalProjectByPath, getProjects, getSshProjectByPath } from './operations/getProjects'; import { getProjectSettings } from './operations/getProjectSettings'; import { openProject } from './operations/openProject'; +import { relocateLocalProject } from './operations/relocateProject'; import { updateProjectConnection } from './operations/updateProjectConnection'; import { updateProjectSettings } from './operations/updateProjectSettings'; @@ -27,4 +28,5 @@ export const projectController = createRPCController({ updateProjectConnection, getProjectBootstrapStatus, openProject, + relocateLocalProject, }); diff --git a/src/main/core/projects/operations/relocateProject.ts b/src/main/core/projects/operations/relocateProject.ts new file mode 100644 index 000000000..7ab8db162 --- /dev/null +++ b/src/main/core/projects/operations/relocateProject.ts @@ -0,0 +1,135 @@ +import { eq } from 'drizzle-orm'; +import { remoteNameFromQualifiedRef, resolveBaseRefFromRemoteDefault } from '@shared/git-utils'; +import type { LocalProject } from '@shared/projects'; +import { err, ok, type Result } from '@shared/result'; +import { GitHubAuthExecutionContext } from '@main/core/execution-context/github-auth-execution-context'; +import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; +import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; +import { GitService } from '@main/core/git/impl/git-service'; +import { githubConnectionService } from '@main/core/github/services/github-connection-service'; +import { projectManager } from '@main/core/projects/project-manager'; +import { db } from '@main/db/client'; +import { projects } from '@main/db/schema'; +import { log } from '@main/lib/logger'; +import { checkIsValidDirectory } from '../path-utils'; +import { getProjectById } from './getProjects'; + +export type RelocateProjectError = + | { type: 'not-found'; message: string } + | { type: 'unsupported'; message: string } + | { type: 'invalid-directory'; message: string } + | { type: 'not-a-git-repo'; message: string } + | { type: 'path-conflict'; message: string; existingProjectId: string } + | { type: 'error'; message: string }; + +async function resolveBaseRef(git: GitService, detectedBaseRef: string): Promise { + const remoteName = remoteNameFromQualifiedRef(detectedBaseRef); + if (!remoteName) return detectedBaseRef; + try { + const [gitDefaultBranch, branches] = await Promise.all([ + git.getDefaultBranch(remoteName), + git.getBranches(), + ]); + return resolveBaseRefFromRemoteDefault({ detectedBaseRef, gitDefaultBranch, branches }); + } catch { + return detectedBaseRef; + } +} + +export async function relocateLocalProject( + projectId: string, + newPath: string +): Promise> { + const project = await getProjectById(projectId); + if (!project) { + return err({ type: 'not-found', message: `Project not found: ${projectId}` }); + } + if (project.type !== 'local') { + return err({ type: 'unsupported', message: 'Only local projects can be relocated' }); + } + + if (!checkIsValidDirectory(newPath)) { + return err({ type: 'invalid-directory', message: `Not a directory: ${newPath}` }); + } + + const fs = new LocalFileSystem(newPath); + const baseCtx = new LocalExecutionContext({ root: newPath }); + const authCtx = new GitHubAuthExecutionContext(baseCtx, () => githubConnectionService.getToken()); + const git = new GitService(baseCtx, authCtx, fs); + + let gitInfo: Awaited>; + try { + gitInfo = await git.detectInfo(); + } catch (e) { + return err({ type: 'error', message: e instanceof Error ? e.message : String(e) }); + } + if (!gitInfo.isGitRepo) { + return err({ type: 'not-a-git-repo', message: 'Selected directory is not a git repository' }); + } + + const resolvedPath = gitInfo.rootPath; + + const [collision] = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.path, resolvedPath)) + .limit(1); + if (collision && collision.id !== projectId) { + return err({ + type: 'path-conflict', + message: 'Another project is already registered at this path', + existingProjectId: collision.id, + }); + } + + const baseRef = await resolveBaseRef(git, gitInfo.baseRef); + + const closeResult = await projectManager.closeProject(projectId); + if (!closeResult.success) { + log.error('relocateLocalProject: failed to close existing provider', { + projectId, + error: closeResult.error, + }); + return err({ + type: 'error', + message: `Failed to close existing project: ${closeResult.error.message}`, + }); + } + + const [row] = await db + .update(projects) + .set({ + path: resolvedPath, + baseRef, + updatedAt: new Date().toISOString(), + }) + .where(eq(projects.id, projectId)) + .returning(); + + if (!row) { + return err({ + type: 'not-found', + message: `Project not found during update: ${projectId}`, + }); + } + + try { + await baseCtx.exec('git', ['worktree', 'repair']); + } catch (e) { + log.warn('relocateLocalProject: git worktree repair failed', { + projectId, + path: resolvedPath, + error: e instanceof Error ? e.message : String(e), + }); + } + + return ok({ + type: 'local' as const, + id: row.id, + name: row.name, + path: row.path, + baseRef: row.baseRef ?? baseRef, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); +} diff --git a/src/renderer/features/projects/components/main-panel/main-panel.tsx b/src/renderer/features/projects/components/main-panel/main-panel.tsx index 72ea1c2df..5245843d0 100644 --- a/src/renderer/features/projects/components/main-panel/main-panel.tsx +++ b/src/renderer/features/projects/components/main-panel/main-panel.tsx @@ -1,7 +1,10 @@ -import { Loader2, TriangleAlert, Unplug } from 'lucide-react'; +import { FolderInput, Loader2, TriangleAlert, Unplug } from 'lucide-react'; import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; +import { rpc } from '@renderer/lib/ipc'; import { useParams } from '@renderer/lib/layout/navigation-provider'; import { appState } from '@renderer/lib/stores/app-state'; +import { Button } from '@renderer/lib/ui/button'; import { isUnregisteredProject } from '../../stores/project'; import { getProjectManagerStore, @@ -28,7 +31,13 @@ export const ProjectMainPanel = observer(function ProjectMainPanel() { } if (kind === 'path_not_found') { - return ; + return ( + + ); } if (kind === 'ssh_disconnected') { @@ -103,7 +112,36 @@ function ProjectSshDisconnectedPanel({ ); } -function ProjectPathNotFoundPanel({ path, projectId }: { path: string; projectId: string }) { +function ProjectPathNotFoundPanel({ + path, + projectId, + projectName, +}: { + path: string; + projectId: string; + projectName: string; +}) { + const [isRelocating, setIsRelocating] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const handleRelocate = async () => { + setErrorMessage(null); + const dialogTitle = projectName ? `Relocate ${projectName}` : 'Relocate Project'; + const newPath = await rpc.app.openSelectDirectoryDialog({ + title: dialogTitle, + message: 'Select the new location of this project', + }); + if (!newPath) return; + setIsRelocating(true); + try { + await getProjectManagerStore().relocateLocalProject(projectId, newPath); + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : String(err)); + } finally { + setIsRelocating(false); + } + }; + return (
@@ -115,13 +153,29 @@ function ProjectPathNotFoundPanel({ path, projectId }: { path: string; projectId

The project directory no longer exists at the configured path.

- + {errorMessage && ( +

{errorMessage}

+ )} +
+ + +
); diff --git a/src/renderer/features/projects/stores/project-manager.ts b/src/renderer/features/projects/stores/project-manager.ts index 8a14e4c15..34c4d4aff 100644 --- a/src/renderer/features/projects/stores/project-manager.ts +++ b/src/renderer/features/projects/stores/project-manager.ts @@ -353,6 +353,32 @@ export class ProjectManagerStore { } } + async relocateLocalProject(projectId: string, newPath: string): Promise { + const result = await rpc.projects.relocateLocalProject(projectId, newPath); + if (!result.success) { + throw new Error(result.error.message); + } + const newData: LocalProject = result.data; + + runInAction(() => { + const current = this.projects.get(projectId); + if (!current) return; + if (isMountedProject(current)) { + current.transitionToUnmounted(newData, 'opening'); + } else if (isUnmountedProject(current)) { + current.data = newData; + current.phase = 'opening'; + current.error = undefined; + current.errorCode = undefined; + } + }); + + const inFlight = this._projectMountPromises.get(projectId); + if (inFlight) await inFlight.catch(() => {}); + + await this.mountProject(projectId); + } + async updateProjectConnection(projectId: string, newConnectionId: string): Promise { await rpc.projects.updateProjectConnection(projectId, newConnectionId); diff --git a/src/renderer/features/sidebar/project-item.tsx b/src/renderer/features/sidebar/project-item.tsx index 509591ba1..e350c8632 100644 --- a/src/renderer/features/sidebar/project-item.tsx +++ b/src/renderer/features/sidebar/project-item.tsx @@ -10,7 +10,7 @@ import { TriangleAlert, } from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { isUnregisteredProject, type UnregisteredProject, @@ -22,6 +22,8 @@ import { projectViewKind, } from '@renderer/features/projects/stores/project-selectors'; import { ConnectionStatusDot } from '@renderer/lib/components/connection-status-dot'; +import { useToast } from '@renderer/lib/hooks/use-toast'; +import { rpc } from '@renderer/lib/ipc'; import { useNavigate, useParams, @@ -84,10 +86,44 @@ export const SidebarProjectItem = observer(function SidebarProjectItem({ const isExpanded = sidebarStore.expandedProjectIds.has(projectId); + const [isRelocating, setIsRelocating] = useState(false); + const { toast } = useToast(); + if (!project) return null; const sshConnectionId = project.data?.type === 'ssh' ? project.data.connectionId : null; const isSshProject = sshConnectionId !== null; + const isLocalProject = project.data?.type === 'local'; + const isPathNotFound = projectViewKind(project) === 'path_not_found'; + + const handleRelocate = async () => { + if (isRelocating) return; + const dialogTitle = project.name ? `Relocate ${project.name}` : 'Relocate Project'; + const newPath = await rpc.app.openSelectDirectoryDialog({ + title: dialogTitle, + message: 'Select the new location of this project', + }); + if (!newPath) return; + setIsRelocating(true); + try { + await getProjectManagerStore().relocateLocalProject(projectId, newPath); + toast({ + title: 'Project relocated', + description: project.name + ? `Moved "${project.name}" to ${newPath}.` + : `Moved to ${newPath}.`, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast({ + title: 'Failed to relocate project', + description: message, + variant: 'destructive', + }); + } finally { + setIsRelocating(false); + } + }; const sshConnectionState = sshConnectionId ? appState.sshConnections.stateFor(sshConnectionId) : null; @@ -207,6 +243,20 @@ export const SidebarProjectItem = observer(function SidebarProjectItem({ )} + {isLocalProject && isPathNotFound && ( + <> + { + void handleRelocate(); + }} + > + + Relocate Project + + + + )} {