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}