diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index c5b20db58e4..3cf65a4878e 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -1,4 +1,4 @@ -import { workspaces, worktrees } from "@superset/local-db"; +import { workspaces } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; @@ -13,6 +13,10 @@ import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + resolveWorktreePathOrThrowWithMetadata, + resolveWorktreePathWithRepair, +} from "../workspaces/utils/repair-worktree-path"; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { resolveTerminalThemeType } from "./theme-type"; import { getWorkspaceTerminalContext, resolveCwd } from "./utils"; @@ -86,9 +90,19 @@ export const createTerminalRouter = () => { themeType, } = input; - const { workspace, workspacePath, rootPath } = - getWorkspaceTerminalContext(workspaceId); - if (workspace?.type === "worktree") { + const { + workspace, + workspacePath: baseWorkspacePath, + rootPath, + } = getWorkspaceTerminalContext(workspaceId); + let workspacePath = baseWorkspacePath; + let pathChanged = false; + if (workspace?.type === "worktree" && workspace.worktreeId) { + const resolved = await resolveWorktreePathOrThrowWithMetadata( + workspace.worktreeId, + ); + workspacePath = resolved.path ?? undefined; + pathChanged = resolved.pathChanged; assertWorkspaceUsable(workspaceId, workspacePath); } const cwd = resolveCwd(cwdOverride, workspacePath); @@ -142,6 +156,7 @@ export const createTerminalRouter = () => { isNew: result.isNew, scrollback: result.scrollback, wasRecovered: result.wasRecovered, + pathChanged, // Cold restore fields (for reboot recovery) isColdRestore: result.isColdRestore, previousCwd: result.previousCwd, @@ -400,7 +415,7 @@ export const createTerminalRouter = () => { getWorkspaceCwd: publicProcedure .input(z.string()) - .query(({ input: workspaceId }) => { + .query(async ({ input: workspaceId }) => { const workspace = localDb .select() .from(workspaces) @@ -414,12 +429,7 @@ export const createTerminalRouter = () => { return null; } - const worktree = localDb - .select() - .from(worktrees) - .where(eq(worktrees.id, workspace.worktreeId)) - .get(); - return worktree?.path ?? null; + return resolveWorktreePathWithRepair(workspace.worktreeId); }), stream: publicProcedure diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index a21e718c4cb..a22e18378d5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -23,6 +23,10 @@ import { fetchGitHubPRStatus, type PullRequestCommentsTarget, } from "../utils/github"; +import { + resolveWorktreePathOrThrow, + resolveWorktreePathWithRepair, +} from "../utils/repair-worktree-path"; const gitHubPRCommentsInputSchema = z.object({ workspaceId: z.string(), @@ -107,8 +111,13 @@ export const createGitStatusProcedures = () => { await fetchDefaultBranch(project.mainRepoPath, defaultBranch); + const worktreePath = await resolveWorktreePathOrThrow(worktree.id); + if (!worktreePath) { + throw new Error(`Worktree ${worktree.id} path could not be resolved`); + } + const { ahead, behind } = await getAheadBehindCount({ - repoPath: worktree.path, + repoPath: worktreePath, defaultBranch, }); @@ -163,7 +172,12 @@ export const createGitStatusProcedures = () => { return null; } - const freshStatus = await fetchGitHubPRStatus(worktree.path); + const worktreePath = await resolveWorktreePathWithRepair(worktree.id); + if (!worktreePath) { + return null; + } + + const freshStatus = await fetchGitHubPRStatus(worktreePath); if (freshStatus) { localDb @@ -192,9 +206,13 @@ export const createGitStatusProcedures = () => { } const cachedGitHubStatus = worktree.githubStatus ?? null; + const worktreePath = await resolveWorktreePathWithRepair(worktree.id); + if (!worktreePath) { + return []; + } return fetchGitHubPRComments({ - worktreePath: worktree.path, + worktreePath, pullRequest: resolveCommentsPullRequestTarget({ input, githubStatus: cachedGitHubStatus, @@ -204,7 +222,7 @@ export const createGitStatusProcedures = () => { getWorktreeInfo: publicProcedure .input(z.object({ workspaceId: z.string() })) - .query(({ input }) => { + .query(async ({ input }) => { const workspace = getWorkspace(input.workspaceId); if (!workspace) { return null; @@ -217,7 +235,8 @@ export const createGitStatusProcedures = () => { return null; } - const worktreeName = worktree.path.split("/").pop() ?? worktree.branch; + const resolvedPath = await resolveWorktreePathWithRepair(worktree.id); + const worktreeName = resolvedPath?.split("/").pop() ?? worktree.branch; const branchName = worktree.branch; return { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 2d602501107..431a40701cf 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,5 +1,7 @@ +import { existsSync } from "node:fs"; import { projects, + type SelectWorkspace, workspaceSections, workspaces, worktrees, @@ -11,11 +13,58 @@ import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getWorkspace } from "../utils/db-helpers"; import { getProjectChildItems } from "../utils/project-children-order"; +import { + resolveWorktreePathWithRepairMetadata, + type TrackedWorktreeRepairState, +} from "../utils/repair-worktree-path"; import { loadSetupConfig } from "../utils/setup"; import { computeVisualOrder } from "../utils/visual-order"; -import { getWorkspacePath } from "../utils/worktree"; -type WorktreePathMap = Map; +interface WorkspaceQueryPathState { + worktreePath: string; + existsOnDisk: boolean; + repairState: TrackedWorktreeRepairState; + repairMessage: string | null; + repairCommand: string | null; +} + +async function resolveWorkspacePathState(input: { + workspace: SelectWorkspace; + mainRepoPath: string | null | undefined; +}): Promise { + if (input.workspace.type === "branch") { + const worktreePath = input.mainRepoPath ?? ""; + return { + worktreePath, + existsOnDisk: !!worktreePath && existsSync(worktreePath), + repairState: "ok", + repairMessage: null, + repairCommand: null, + }; + } + + if (!input.workspace.worktreeId) { + return { + worktreePath: "", + existsOnDisk: false, + repairState: "missing", + repairMessage: "Tracked worktree could not be found.", + repairCommand: null, + }; + } + + const resolution = await resolveWorktreePathWithRepairMetadata( + input.workspace.worktreeId, + ); + + return { + worktreePath: resolution.path ?? "", + existsOnDisk: !!resolution.path && existsSync(resolution.path), + repairState: resolution.repairState, + repairMessage: resolution.repairMessage, + repairCommand: resolution.repairCommand, + }; +} /** Returns workspace IDs in sidebar visual order (by project.tabOrder, then ungrouped workspaces, then sections by tabOrder). */ function getWorkspacesInVisualOrder(): string[] { @@ -61,11 +110,19 @@ export const createQueryProcedures = () => { .where(eq(worktrees.id, workspace.worktreeId)) .get() : null; + const pathState = await resolveWorkspacePathState({ + workspace, + mainRepoPath: project?.mainRepoPath, + }); return { ...workspace, type: workspace.type as "worktree" | "branch", - worktreePath: getWorkspacePath(workspace) ?? "", + worktreePath: pathState.worktreePath, + existsOnDisk: pathState.existsOnDisk, + repairState: pathState.repairState, + repairMessage: pathState.repairMessage, + repairCommand: pathState.repairCommand, project: project ? { id: project.id, @@ -94,13 +151,17 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder); }), - getAllGrouped: publicProcedure.query(() => { + getAllGrouped: publicProcedure.query(async () => { type WorkspaceItem = { id: string; projectId: string; sectionId: string | null; worktreeId: string | null; worktreePath: string; + existsOnDisk: boolean; + repairState: TrackedWorktreeRepairState; + repairMessage: string | null; + repairCommand: string | null; type: "worktree" | "branch"; branch: string; name: string; @@ -134,11 +195,6 @@ export const createQueryProcedures = () => { .where(isNotNull(projects.tabOrder)) .all(); - const allWorktrees = localDb.select().from(worktrees).all(); - const worktreePathMap: WorktreePathMap = new Map( - allWorktrees.map((wt) => [wt.id, wt.path]), - ); - const allSections = localDb.select().from(workspaceSections).all(); const groupsMap = new Map< @@ -199,38 +255,53 @@ export const createQueryProcedures = () => { .all() .sort((a, b) => a.tabOrder - b.tabOrder); - for (const workspace of allWorkspaces) { - const group = groupsMap.get(workspace.projectId); - if (group) { - let worktreePath = ""; - if (workspace.type === "worktree" && workspace.worktreeId) { - worktreePath = worktreePathMap.get(workspace.worktreeId) ?? ""; - } else if (workspace.type === "branch") { - worktreePath = group.project.mainRepoPath; + const resolvedWorkspaces = await Promise.all( + allWorkspaces.map(async (workspace) => { + const group = groupsMap.get(workspace.projectId); + if (!group) { + return null; } + const pathState = await resolveWorkspacePathState({ + workspace, + mainRepoPath: group.project.mainRepoPath, + }); + const item: WorkspaceItem = { ...workspace, sectionId: workspace.sectionId ?? null, type: workspace.type as "worktree" | "branch", - worktreePath, + worktreePath: pathState.worktreePath, + existsOnDisk: pathState.existsOnDisk, + repairState: pathState.repairState, + repairMessage: pathState.repairMessage, + repairCommand: pathState.repairCommand, isUnread: workspace.isUnread ?? false, isUnnamed: workspace.isUnnamed ?? false, }; - if (workspace.sectionId) { - const section = group.sections.find( - (s) => s.id === workspace.sectionId, - ); - if (section) { - section.workspaces.push(item); - } else { - // Orphan: section not found, fall back to ungrouped - group.workspaces.push(item); - } + return { workspace, group, item }; + }), + ); + + for (const resolvedWorkspace of resolvedWorkspaces) { + if (!resolvedWorkspace) { + continue; + } + + const { workspace, group, item } = resolvedWorkspace; + if (workspace.sectionId) { + const section = group.sections.find( + (s) => s.id === workspace.sectionId, + ); + if (section) { + section.workspaces.push(item); } else { + // Orphan: section not found, fall back to ungrouped group.workspaces.push(item); } + } else { + group.workspaces.push(item); } } @@ -291,7 +362,7 @@ export const createQueryProcedures = () => { getResolvedRunCommands: publicProcedure .input(z.object({ workspaceId: z.string() })) - .query(({ input }) => { + .query(async ({ input }) => { const workspace = localDb .select() .from(workspaces) @@ -313,17 +384,13 @@ export const createQueryProcedures = () => { return { commands: [] }; } - const worktree = workspace.worktreeId - ? localDb - .select() - .from(worktrees) - .where(eq(worktrees.id, workspace.worktreeId)) - .get() - : null; - const worktreePath = - workspace.type === "worktree" && worktree?.path - ? worktree.path + workspace.type === "worktree" && workspace.worktreeId + ? (( + await resolveWorktreePathWithRepairMetadata( + workspace.worktreeId, + ) + ).path ?? undefined) : workspace.type === "branch" ? project.mainRepoPath : undefined; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/repair.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/repair.ts new file mode 100644 index 00000000000..d724b811679 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/repair.ts @@ -0,0 +1,38 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { publicProcedure, router } from "../../.."; +import { getWorkspace } from "../utils/db-helpers"; +import { repairTrackedWorktreePath as repairTrackedWorktreePathUtil } from "../utils/repair-worktree-path"; + +export const createRepairProcedures = () => { + return router({ + repairTrackedWorktreePath: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + selectedPath: z.string(), + }), + ) + .mutation(async ({ input }) => { + const workspace = getWorkspace(input.workspaceId); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${input.workspaceId} not found`, + }); + } + + if (workspace.type !== "worktree" || !workspace.worktreeId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Only tracked worktree workspaces can be repaired", + }); + } + + return repairTrackedWorktreePathUtil({ + worktreeId: workspace.worktreeId, + selectedPath: input.selectedPath, + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 0df864005b5..d4d203bcede 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -822,6 +822,22 @@ export async function getBranchWorktreePath({ } } +export async function repairWorktreeRegistration({ + mainRepoPath, + worktreePath, +}: { + mainRepoPath: string; + worktreePath: string; +}): Promise { + try { + const git = await getSimpleGitWithShellPath(mainRepoPath); + await git.raw(["worktree", "repair", worktreePath]); + } catch (error) { + console.error(`Failed to repair worktree registration: ${error}`); + throw error; + } +} + export async function hasOriginRemote(mainRepoPath: string): Promise { try { const git = await getSimpleGitWithShellPath(mainRepoPath); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts new file mode 100644 index 00000000000..4a104943672 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/repair-worktree-path.ts @@ -0,0 +1,398 @@ +import { existsSync, realpathSync } from "node:fs"; +import { projects, type SelectWorktree, worktrees } from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { + getBranchWorktreePath, + getCurrentBranch, + getGitRoot, + repairWorktreeRegistration, +} from "./git"; + +export type ResolveTrackedWorktreePathResult = + | { + status: "resolved"; + path: string; + } + | { + status: "git_repair_required"; + branch: string; + mainRepoPath: string; + registeredPath: string | null; + storedPath: string; + } + | { + status: "missing"; + }; + +export type TrackedWorktreeRepairState = "ok" | "missing" | "repair_required"; + +interface TrackedWorktreeContext { + mainRepoPath: string; + worktree: SelectWorktree; +} + +interface ResolveTrackedWorktreePathWithMetadataResult { + pathChanged: boolean; + resolution: ResolveTrackedWorktreePathResult; +} + +function safeRealpath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function getTrackedWorktreeContext( + worktreeId: string, +): TrackedWorktreeContext | null { + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, worktreeId)) + .get(); + if (!worktree) { + return null; + } + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, worktree.projectId)) + .get(); + if (!project) { + return null; + } + + return { + mainRepoPath: project.mainRepoPath, + worktree, + }; +} + +function isMainRepoPath( + context: TrackedWorktreeContext, + candidatePath: string, +): boolean { + return safeRealpath(candidatePath) === safeRealpath(context.mainRepoPath); +} + +function persistResolvedTrackedWorktreePath(input: { + context: TrackedWorktreeContext; + resolvedPath: string; +}): ResolveTrackedWorktreePathWithMetadataResult { + if (isMainRepoPath(input.context, input.resolvedPath)) { + return { + pathChanged: false, + resolution: { status: "missing" }, + }; + } + + const pathChanged = input.resolvedPath !== input.context.worktree.path; + if (pathChanged) { + localDb + .update(worktrees) + .set({ path: input.resolvedPath }) + .where(eq(worktrees.id, input.context.worktree.id)) + .run(); + } + + return { + pathChanged, + resolution: { + status: "resolved", + path: input.resolvedPath, + }, + }; +} + +export function getTrackedWorktreeRepairCommand(mainRepoPath: string): string { + return `git -C "${mainRepoPath}" worktree repair `; +} + +export function getTrackedWorktreeRepairMessage(input: { + branch: string; + mainRepoPath: string; +}): string { + return `Worktree branch "${input.branch}" is missing at its tracked path. Select the moved worktree folder and Superset will repair it, or run ${getTrackedWorktreeRepairCommand(input.mainRepoPath)} manually.`; +} + +async function resolveTrackedWorktreePathWithMetadata( + worktreeId: string, +): Promise { + const context = getTrackedWorktreeContext(worktreeId); + if (!context) { + return { + pathChanged: false, + resolution: { status: "missing" }, + }; + } + + if (existsSync(context.worktree.path)) { + return { + pathChanged: false, + resolution: { + status: "resolved", + path: context.worktree.path, + }, + }; + } + + let registeredPath: string | null = null; + try { + registeredPath = await getBranchWorktreePath({ + mainRepoPath: context.mainRepoPath, + branch: context.worktree.branch, + }); + } catch (error) { + console.warn( + `[repair-worktree-path] Failed to inspect Git worktree state for ${context.worktree.id}:`, + error instanceof Error ? error.message : error, + ); + } + + if ( + registeredPath && + existsSync(registeredPath) && + !isMainRepoPath(context, registeredPath) + ) { + return persistResolvedTrackedWorktreePath({ + context, + resolvedPath: registeredPath, + }); + } + + return { + pathChanged: false, + resolution: { + status: "git_repair_required", + branch: context.worktree.branch, + mainRepoPath: context.mainRepoPath, + registeredPath, + storedPath: context.worktree.path, + }, + }; +} + +export async function resolveTrackedWorktreePath( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + return resolution.resolution; +} + +export async function resolveWorktreePathOrThrow( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + + if (resolution.resolution.status === "resolved") { + return resolution.resolution.path; + } + + if (resolution.resolution.status === "git_repair_required") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: getTrackedWorktreeRepairMessage({ + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + }), + cause: { + reason: "git_repair_required", + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + registeredPath: resolution.resolution.registeredPath, + storedPath: resolution.resolution.storedPath, + command: getTrackedWorktreeRepairCommand( + resolution.resolution.mainRepoPath, + ), + }, + }); + } + + return null; +} + +export async function resolveWorktreePathOrThrowWithMetadata( + worktreeId: string, +): Promise<{ + path: string | null; + pathChanged: boolean; +}> { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + + if (resolution.resolution.status === "resolved") { + return { + path: resolution.resolution.path, + pathChanged: resolution.pathChanged, + }; + } + + if (resolution.resolution.status === "git_repair_required") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: getTrackedWorktreeRepairMessage({ + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + }), + cause: { + reason: "git_repair_required", + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + registeredPath: resolution.resolution.registeredPath, + storedPath: resolution.resolution.storedPath, + command: getTrackedWorktreeRepairCommand( + resolution.resolution.mainRepoPath, + ), + }, + }); + } + + return { path: null, pathChanged: false }; +} + +export async function resolveWorktreePathWithRepair( + worktreeId: string, +): Promise { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + return resolution.resolution.status === "resolved" + ? resolution.resolution.path + : null; +} + +export async function resolveWorktreePathWithRepairMetadata( + worktreeId: string, +): Promise<{ + path: string | null; + pathChanged: boolean; + repairState: TrackedWorktreeRepairState; + repairMessage: string | null; + repairCommand: string | null; +}> { + const resolution = await resolveTrackedWorktreePathWithMetadata(worktreeId); + + if (resolution.resolution.status === "resolved") { + return { + path: resolution.resolution.path, + pathChanged: resolution.pathChanged, + repairState: "ok", + repairMessage: null, + repairCommand: null, + }; + } + + if (resolution.resolution.status === "git_repair_required") { + return { + path: null, + pathChanged: false, + repairState: "repair_required", + repairMessage: getTrackedWorktreeRepairMessage({ + branch: resolution.resolution.branch, + mainRepoPath: resolution.resolution.mainRepoPath, + }), + repairCommand: getTrackedWorktreeRepairCommand( + resolution.resolution.mainRepoPath, + ), + }; + } + + return { + path: null, + pathChanged: false, + repairState: "missing", + repairMessage: "Tracked worktree could not be found.", + repairCommand: null, + }; +} + +export async function repairTrackedWorktreePath(input: { + worktreeId: string; + selectedPath: string; +}): Promise<{ + path: string; + pathChanged: boolean; +}> { + const context = getTrackedWorktreeContext(input.worktreeId); + if (!context) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Tracked worktree ${input.worktreeId} not found`, + }); + } + + let candidatePath: string; + try { + candidatePath = await getGitRoot(input.selectedPath); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Selected path is not a Git worktree", + }); + } + + if (!existsSync(candidatePath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Selected worktree path does not exist on disk", + }); + } + + if (isMainRepoPath(context, candidatePath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Select the moved worktree folder, not the main repository", + }); + } + + const currentBranch = await getCurrentBranch(candidatePath); + if (currentBranch !== context.worktree.branch) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Selected folder is on branch "${currentBranch ?? "detached"}", expected "${context.worktree.branch}"`, + }); + } + + await repairWorktreeRegistration({ + mainRepoPath: context.mainRepoPath, + worktreePath: candidatePath, + }); + + const repairedPath = await getBranchWorktreePath({ + mainRepoPath: context.mainRepoPath, + branch: context.worktree.branch, + }); + + if ( + !repairedPath || + !existsSync(repairedPath) || + isMainRepoPath(context, repairedPath) + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Git could not confirm the repaired worktree path", + }); + } + + const persisted = persistResolvedTrackedWorktreePath({ + context, + resolvedPath: repairedPath, + }); + + if (persisted.resolution.status !== "resolved") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Failed to persist repaired worktree path", + }); + } + + return { + path: persisted.resolution.path, + pathChanged: persisted.pathChanged, + }; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 5c0cbdd2564..c9952d83687 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -5,6 +5,7 @@ import { createGenerateBranchNameProcedures } from "./procedures/generate-branch import { createGitStatusProcedures } from "./procedures/git-status"; import { createInitProcedures } from "./procedures/init"; import { createQueryProcedures } from "./procedures/query"; +import { createRepairProcedures } from "./procedures/repair"; import { createSectionsProcedures } from "./procedures/sections"; import { createStatusProcedures } from "./procedures/status"; @@ -13,6 +14,7 @@ export const createWorkspacesRouter = () => { createCreateProcedures(), createDeleteProcedures(), createQueryProcedures(), + createRepairProcedures(), createGitStatusProcedures(), createStatusProcedures(), createInitProcedures(), diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 0e8928ea7d2..d36a5c39d57 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -11,6 +11,7 @@ export { useMoveWorkspaceToSection } from "./useMoveWorkspaceToSection"; export { useOpenExternalWorktree } from "./useOpenExternalWorktree"; export { useOpenMainRepoWorkspace } from "./useOpenMainRepoWorkspace"; export { useOpenTrackedWorktree } from "./useOpenTrackedWorktree"; +export { useRecoverTrackedWorktree } from "./useRecoverTrackedWorktree"; export { useReorderProjectChildren } from "./useReorderProjectChildren"; export { useReorderSections } from "./useReorderSections"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useRecoverTrackedWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useRecoverTrackedWorktree.ts new file mode 100644 index 00000000000..d8f8265c287 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useRecoverTrackedWorktree.ts @@ -0,0 +1,52 @@ +import { toast } from "@superset/ui/sonner"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface UseRecoverTrackedWorktreeOptions { + workspaceId: string; + defaultPath?: string | null; +} + +export function useRecoverTrackedWorktree({ + workspaceId, + defaultPath, +}: UseRecoverTrackedWorktreeOptions) { + const utils = electronTrpc.useUtils(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const repairTrackedWorktree = + electronTrpc.workspaces.repairTrackedWorktreePath.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.workspaces.invalidate(), + utils.terminal.invalidate(), + ]); + toast.success("Worktree recovered"); + }, + onError: (error) => { + toast.error(`Failed to recover worktree: ${error.message}`); + }, + }); + + const recoverTrackedWorktree = async () => { + const result = await selectDirectory.mutateAsync({ + title: "Select moved worktree folder", + defaultPath: defaultPath ?? undefined, + }); + if (result.canceled || !result.path) { + return; + } + + try { + await repairTrackedWorktree.mutateAsync({ + workspaceId, + selectedPath: result.path, + }); + } catch { + // Mutation onError already surfaces the failure to the user. + } + }; + + return { + recoverTrackedWorktree, + isPending: selectDirectory.isPending || repairTrackedWorktree.isPending, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 50c168f8818..87c7fd4ad0a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,3 +1,4 @@ +import { Button } from "@superset/ui/button"; import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; @@ -6,6 +7,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { usePresets } from "renderer/react-query/presets"; +import { useRecoverTrackedWorktree } from "renderer/react-query/workspaces"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; @@ -123,8 +125,12 @@ function WorkspacePage() { // Check for incomplete init after app restart const gitStatus = workspace?.worktree?.gitStatus; + const showRecoveryView = + workspace?.type === "worktree" && + workspace.repairState === "repair_required"; const hasIncompleteInit = workspace?.type === "worktree" && + !showRecoveryView && (gitStatus === null || gitStatus === undefined); // Show full-screen initialization view for: @@ -375,6 +381,11 @@ function WorkspacePage() { // Copy path shortcut const { copyToClipboard } = useCopyToClipboard(); + const { recoverTrackedWorktree, isPending: isRecoverTrackedWorktreePending } = + useRecoverTrackedWorktree({ + workspaceId, + defaultPath: workspace?.project?.mainRepoPath, + }); useAppHotkey( "COPY_PATH", () => { @@ -385,6 +396,13 @@ function WorkspacePage() { undefined, [workspace?.worktreePath], ); + const handleCopyRepairCommand = useCallback(() => { + if (!workspace?.repairCommand) { + return; + } + + void copyToClipboard(workspace.repairCommand); + }, [copyToClipboard, workspace?.repairCommand]); // Open PR shortcut (⌘⇧P) const { pr } = usePRStatus({ workspaceId }); @@ -632,6 +650,43 @@ function WorkspacePage() { workspaceName={workspace?.name ?? "Workspace"} isInterrupted={hasIncompleteInit && !isInitializing} /> + ) : showRecoveryView ? ( +
+
+
+

Recover worktree

+

+ {workspace?.repairMessage} +

+
+ {workspace?.repairCommand && ( +
+									{workspace.repairCommand}
+								
+ )} +
+ + {workspace?.repairCommand && ( + + )} +
+
+
) : ( void; onRename: () => void; onOpenInFinder: () => void; onCopyPath: () => void; @@ -56,9 +59,11 @@ export function WorkspaceContextMenu({ projectId, name, isBranchWorkspace, + isRepairRequired, isUnread, workspaceStatus, sections, + onRecoverWorktree, onRename, onOpenInFinder, onCopyPath, @@ -196,6 +201,15 @@ export function WorkspaceContextMenu({ {children} + {isRepairRequired && ( + <> + + + Recover worktree + + + + )} Rename diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 2ba8b45addf..5bf127a81d6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -4,10 +4,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; -import { HiMiniXMark } from "react-icons/hi2"; +import { HiMiniExclamationTriangle, HiMiniXMark } from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; +import { + useRecoverTrackedWorktree, + useWorkspaceDeleteHandler, +} from "renderer/react-query/workspaces"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { WorkspaceRunIndicator } from "renderer/screens/main/components/WorkspaceRunIndicator"; import { useBranchSyncInvalidation } from "renderer/screens/main/hooks/useBranchSyncInvalidation"; @@ -35,6 +38,9 @@ interface WorkspaceListItemProps { id: string; projectId: string; worktreePath: string; + repairCommand?: string | null; + repairMessage?: string | null; + repairState?: "ok" | "missing" | "repair_required" | "repairing"; name: string; branch: string; type: "worktree" | "branch"; @@ -51,6 +57,9 @@ export function WorkspaceListItem({ id, projectId, worktreePath, + repairCommand, + repairMessage, + repairState = "ok", name, branch, type, @@ -129,6 +138,10 @@ export function WorkspaceListItem({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); + const { recoverTrackedWorktree } = useRecoverTrackedWorktree({ + workspaceId: id, + defaultPath: worktreePath || undefined, + }); const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( @@ -232,6 +245,9 @@ export function WorkspaceListItem({ : null); const showBranchSubtitle = isBranchWorkspace || (!!name && name !== branch); + const isRepairRequired = + !isBranchWorkspace && repairState === "repair_required"; + const shouldShowRepairWarning = isRepairRequired && !!repairMessage; if (isCollapsed) { return ( @@ -363,6 +379,30 @@ export function WorkspaceListItem({ > {isBranchWorkspace ? "local" : name || branch} + {shouldShowRepairWarning && ( + + + + + + + +

Recover worktree

+

+ {repairMessage} +

+ {repairCommand && ( + + {repairCommand} + + )} +
+
+ )} {isBranchWorkspace && aheadBehind && ( void recoverTrackedWorktree()} onRename={rename.startRename} onOpenInFinder={handleOpenInFinder} onCopyPath={handleCopyPath} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts index bdb6ef2a4e4..e285ba250d0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts @@ -2,11 +2,15 @@ export interface SidebarWorkspace { id: string; projectId: string; worktreePath: string; + existsOnDisk: boolean; type: "worktree" | "branch"; branch: string; name: string; tabOrder: number; isUnread: boolean; + repairCommand?: string | null; + repairMessage?: string | null; + repairState?: "ok" | "missing" | "repair_required" | "repairing"; } export interface DragItem {