From cf6ac7270e05dc082c5e29e2e64cf044ca86a2c6 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:29:25 +0200 Subject: [PATCH] feat: rich project creation with folder and git wiring (#73) Extend the existing NewSpaceDialog project flow so an optional host folder becomes the space workspace root. When .git metadata is present, remote URL, default branch, and head commit are stored on the space. Team Computer file APIs expose the linked folder under project/ without a second VCS stack. Desktop adds a native folder picker; web falls back to an absolute path field. Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- apps/api/src/router.ts | 66 ++++++++++- apps/desktop/src/main.ts | 8 ++ apps/desktop/src/preload.cjs | 3 + apps/web/scripts/translations-de.json | 4 + apps/web/src/pages/Shell.tsx | 7 +- apps/web/src/pages/shell/dialogs.tsx | 69 +++++++++++- packages/adapters/src/computer-support.ts | 2 + packages/adapters/src/index.ts | 2 + .../adapters/src/project-workspace-files.ts | 52 +++++++++ .../src/project-workspace-path.test.ts | 40 +++++++ .../adapters/src/project-workspace-path.ts | 32 ++++++ packages/contracts/src/desktop.ts | 4 + packages/contracts/src/domain.ts | 16 +++ packages/contracts/src/rpc.ts | 9 +- packages/core/src/git-checkout.test.ts | 67 +++++++++++ packages/core/src/git-checkout.ts | 85 ++++++++++++++ packages/core/src/index.ts | 1 + .../migration.sql | 5 + packages/db/prisma/schema.prisma | 4 + packages/db/src/index.ts | 1 + packages/db/src/project-workspace.test.ts | 82 ++++++++++++++ packages/db/src/project-workspace.ts | 104 ++++++++++++++++++ 22 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 packages/adapters/src/project-workspace-files.ts create mode 100644 packages/adapters/src/project-workspace-path.test.ts create mode 100644 packages/adapters/src/project-workspace-path.ts create mode 100644 packages/core/src/git-checkout.test.ts create mode 100644 packages/core/src/git-checkout.ts create mode 100644 packages/db/prisma/migrations/20260922103000_space_project_workspace/migration.sql create mode 100644 packages/db/src/project-workspace.test.ts create mode 100644 packages/db/src/project-workspace.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 4d3b3b1..5d978f9 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -52,9 +52,11 @@ import { hasActiveComputerControl, isAutoReviewCheckerConfigured, isComputerScreenUnavailable, + isProjectWorkspacePath, isSandboxGoneError, isScratchpadStatus, listPiCatalog, + listProjectWorkspaceFiles, listScratchpadItems, McpOAuthBroker, mapScratchpadItem, @@ -66,6 +68,7 @@ import { provisionComputer, queueComputerUpdate, releaseComputerExecutionLease, + readProjectWorkspaceFile, replaceComputer, resolveAutoReviewChecker, resolveBotWorkspacePath, @@ -116,8 +119,11 @@ import { findDefaultVoiceCredential, findModelCredential, formatMessagingLinkCode, + InvalidProjectWorkspacePathError, InvalidSpaceNameError, IsolationError, + projectWorkspaceDto, + wireProjectWorkspace, isProjectGoalStatus, issueMessagingLinkCode, listSpaceUserBotIds, @@ -549,12 +555,27 @@ export function createRouter(deps: RouterDeps) { } throw error; } + let workspace = null; + if (input.workspacePath) { + try { + workspace = await wireProjectWorkspace(deps.prisma, { + spaceId: space.id, + workspacePath: input.workspacePath, + }); + } catch (error) { + if (error instanceof InvalidProjectWorkspacePathError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } + } return { id: space.id, name: space.name, isDefault: false, hasContent: false, canDelete: true, + workspace, bots: [], groups: [], externalConversations: [], @@ -2090,6 +2111,14 @@ export function createRouter(deps: RouterDeps) { throw error; } } + if (isProjectWorkspacePath(storedPath)) { + const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor.spaceId); + const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath); + return entries.map((entry) => ({ + ...entry, + path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path), + })); + } let entries: Awaited>; if (computer.state === "running" && computer.providerRef) { await deps.prisma.computer.updateMany({ @@ -2130,6 +2159,16 @@ export function createRouter(deps: RouterDeps) { throw error; } } + if (isProjectWorkspacePath(storedPath)) { + const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor.spaceId); + const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, { + maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, + }); + return { + path: input.path, + content: new TextDecoder("utf-8", { fatal: true }).decode(bytes), + }; + } let content: string; if (bot.computer.state === "running" && bot.computer.providerRef) { await deps.prisma.computer.updateMany({ @@ -4709,6 +4748,20 @@ function mapUpdaterError(error: unknown): never { }); } +async function requireProjectWorkspaceRoot( + prisma: Pick, + spaceId: string, +): Promise { + const space = await prisma.space.findUnique({ + where: { id: spaceId }, + select: { workspacePath: true }, + }); + if (!space?.workspacePath) { + throw new ORPCError("NOT_FOUND", { message: "This project has no linked folder." }); + } + return space.workspacePath; +} + async function spaceNavigationDto( deps: RouterDeps, actor: Actor, @@ -4725,7 +4778,17 @@ async function spaceNavigationDto( select: { spaceId: true, role: true, - space: { select: { name: true, isDefault: true, deletingAt: true } }, + space: { + select: { + name: true, + isDefault: true, + deletingAt: true, + workspacePath: true, + gitRemoteUrl: true, + gitDefaultBranch: true, + gitHeadCommit: true, + }, + }, }, orderBy: { createdAt: "asc" }, }); @@ -4800,6 +4863,7 @@ async function spaceNavigationDto( memberships.length > 1 && !spacesWithContent.has(membership.spaceId) && (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), + workspace: projectWorkspaceDto(membership.space), bots: spaceBots.map((bot) => ({ id: bot.id, spaceId: bot.spaceId, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 833d544..b18264d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1130,6 +1130,14 @@ app.whenReady().then(async () => { }, ); ipcMain.handle("desktop.platform", () => process.platform); + ipcMain.handle("desktop.files.pickDirectory", async (event) => { + if (!fromMainWindow(event)) return null; + const result = await dialog.showOpenDialog(windowFrom(event)!, { + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0] ?? null; + }); ipcMain.handle("desktop.window.close", (event) => { windowFrom(event)?.close(); }); diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs index 7cee756..14e5c50 100644 --- a/apps/desktop/src/preload.cjs +++ b/apps/desktop/src/preload.cjs @@ -2,6 +2,9 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("rakazoDesktop", { platform: process.platform, + files: { + pickDirectory: () => ipcRenderer.invoke("desktop.files.pickDirectory"), + }, localSettings: { request: (pathname, body) => ipcRenderer.invoke("desktop.localSettings.request", pathname, body), diff --git a/apps/web/scripts/translations-de.json b/apps/web/scripts/translations-de.json index 0fe5fb0..d26197a 100644 --- a/apps/web/scripts/translations-de.json +++ b/apps/web/scripts/translations-de.json @@ -489,7 +489,11 @@ "Create a project": "Projekt erstellen", "Create project": "Projekt erstellen", "Could not create project": "Projekt konnte nicht erstellt werden", + "Choose an existing folder on this computer. Git remotes are linked automatically when a repository is present.": "Wähle einen vorhandenen Ordner auf diesem Computer. Git-Remotes werden automatisch verknüpft, wenn ein Repository vorhanden ist.", + "Choose folder": "Ordner wählen", + "Enter an absolute folder path on the server. Git remotes are linked automatically when a repository is present.": "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden automatisch verknüpft, wenn ein Repository vorhanden ist.", "New project": "Neues Projekt", + "Project folder": "Projektordner", "No projects yet.": "Noch keine Projekte.", "Threads by topic · folders": "Threads nach Thema · Ordner", "Settings": "Einstellungen", diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index baf29b1..51831c7 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -4878,8 +4878,11 @@ export function ShellPage() { setNewSpaceOpen(false)} - onConfirm={async (name) => { - const space = await rpc.spaces.create({ name }); + onConfirm={async ({ name, workspacePath }) => { + const space = await rpc.spaces.create({ + name, + ...(workspacePath ? { workspacePath } : {}), + }); setNewSpaceOpen(false); if (!selectSpace(space.id)) { await refreshBots(); diff --git a/apps/web/src/pages/shell/dialogs.tsx b/apps/web/src/pages/shell/dialogs.tsx index 2a58a41..b094ce1 100644 --- a/apps/web/src/pages/shell/dialogs.tsx +++ b/apps/web/src/pages/shell/dialogs.tsx @@ -18,8 +18,9 @@ import { DialogTitle, Input, } from "@rakazo/ui-web"; -import { Lock, Users } from "lucide-react"; +import { FolderOpen, Lock, Users } from "lucide-react"; import { useId, useState } from "react"; +import { desktopBridge } from "../../lib/desktop"; /** Each dialog is mounted only while open, so `open` is always true and the * parent unmounts it from `onCancel`. Escape and backdrop presses are ignored @@ -30,6 +31,11 @@ function closeUnlessBusy(busy: boolean, onCancel: () => void) { }; } +export type NewSpaceConfirmInput = { + name: string; + workspacePath?: string; +}; + export function NewSpaceDialog({ variant = "space", onCancel, @@ -37,21 +43,28 @@ export function NewSpaceDialog({ }: { variant?: "space" | "project"; onCancel: () => void; - onConfirm: (name: string) => Promise; + onConfirm: (input: NewSpaceConfirmInput) => Promise; }) { const { t } = useLingui(); const nameId = useId(); + const folderId = useId(); const [name, setName] = useState(""); + const [workspacePath, setWorkspacePath] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const isProject = variant === "project"; + const desktop = desktopBridge(); + const trimmedWorkspacePath = workspacePath.trim(); const create = () => { const trimmed = name.trim(); if (!trimmed || saving) return; setSaving(true); setError(null); - void onConfirm(trimmed).catch((reason: unknown) => { + void onConfirm({ + name: trimmed, + ...(isProject && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), + }).catch((reason: unknown) => { setError( reason instanceof Error ? reason.message @@ -91,6 +104,56 @@ export function NewSpaceDialog({ className="mt-2" /> + {isProject ? ( +
+ +

+ {desktop?.files?.pickDirectory ? ( + + Choose an existing folder on this computer. Git remotes are linked automatically + when a repository is present. + + ) : ( + + Enter an absolute folder path on the server. Git remotes are linked automatically + when a repository is present. + + )} +

+
+ ) : null} {error ?

{error}

: null}