diff --git a/app/api/git/multi-status/route.ts b/app/api/git/multi-status/route.ts new file mode 100644 index 0000000..f97845b --- /dev/null +++ b/app/api/git/multi-status/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getProjectRepositories, getProject } from "@/lib/projects"; +import { getMultiRepoGitStatus } from "@/lib/multi-repo-git"; +import { expandPath } from "@/lib/git-status"; + +// GET /api/git/multi-status - Get aggregated git status for a project's repositories +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const projectId = searchParams.get("projectId"); + const fallbackPath = searchParams.get("fallbackPath"); + + if (!projectId && !fallbackPath) { + return NextResponse.json( + { error: "Either projectId or fallbackPath is required" }, + { status: 400 } + ); + } + + let repositories: ReturnType = []; + + if (projectId) { + const project = getProject(projectId); + if (!project) { + return NextResponse.json( + { error: "Project not found" }, + { status: 404 } + ); + } + repositories = getProjectRepositories(projectId); + } + + // Get aggregated status + const expandedFallback = fallbackPath + ? expandPath(fallbackPath) + : undefined; + const status = getMultiRepoGitStatus(repositories, expandedFallback); + + return NextResponse.json(status); + } catch (error) { + console.error("Error fetching multi-repo git status:", error); + return NextResponse.json( + { error: "Failed to fetch git status" }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[id]/repositories/[repoId]/route.ts b/app/api/projects/[id]/repositories/[repoId]/route.ts new file mode 100644 index 0000000..03b97e3 --- /dev/null +++ b/app/api/projects/[id]/repositories/[repoId]/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + updateProjectRepository, + deleteProjectRepository, +} from "@/lib/projects"; +import { queries, db, type ProjectRepository } from "@/lib/db"; + +interface RouteParams { + params: Promise<{ id: string; repoId: string }>; +} + +// PATCH /api/projects/[id]/repositories/[repoId] - Update a repository +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { repoId } = await params; + + const existing = queries.getProjectRepository(db).get(repoId) as + | (Omit & { is_primary: number }) + | undefined; + if (!existing) { + return NextResponse.json( + { error: "Repository not found" }, + { status: 404 } + ); + } + + const body = await request.json(); + const { name, path, isPrimary, sortOrder } = body; + + const repository = updateProjectRepository(repoId, { + name, + path, + isPrimary, + sortOrder, + }); + + return NextResponse.json({ repository }); + } catch (error) { + console.error("Error updating repository:", error); + return NextResponse.json( + { error: "Failed to update repository" }, + { status: 500 } + ); + } +} + +// DELETE /api/projects/[id]/repositories/[repoId] - Delete a repository +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { repoId } = await params; + + const existing = queries.getProjectRepository(db).get(repoId) as + | (Omit & { is_primary: number }) + | undefined; + if (!existing) { + return NextResponse.json( + { error: "Repository not found" }, + { status: 404 } + ); + } + + deleteProjectRepository(repoId); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting repository:", error); + return NextResponse.json( + { error: "Failed to delete repository" }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[id]/repositories/route.ts b/app/api/projects/[id]/repositories/route.ts new file mode 100644 index 0000000..d1fcfc4 --- /dev/null +++ b/app/api/projects/[id]/repositories/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getProject, + getProjectRepositories, + addProjectRepository, +} from "@/lib/projects"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// GET /api/projects/[id]/repositories - List repositories for a project +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const project = getProject(id); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const repositories = getProjectRepositories(id); + return NextResponse.json({ repositories }); + } catch (error) { + console.error("Error fetching repositories:", error); + return NextResponse.json( + { error: "Failed to fetch repositories" }, + { status: 500 } + ); + } +} + +// POST /api/projects/[id]/repositories - Add a repository to a project +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const project = getProject(id); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + if (project.is_uncategorized) { + return NextResponse.json( + { error: "Cannot add repositories to Uncategorized project" }, + { status: 400 } + ); + } + + const body = await request.json(); + const { name, path, isPrimary } = body; + + if (!name || !path) { + return NextResponse.json( + { error: "Name and path are required" }, + { status: 400 } + ); + } + + const repository = addProjectRepository(id, { + name, + path, + isPrimary, + }); + + return NextResponse.json({ repository }, { status: 201 }); + } catch (error) { + console.error("Error adding repository:", error); + return NextResponse.json( + { error: "Failed to add repository" }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts index 3848bde..8f0d63c 100644 --- a/app/api/projects/[id]/route.ts +++ b/app/api/projects/[id]/route.ts @@ -35,7 +35,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { try { const { id } = await params; const body = await request.json(); - const { name, workingDirectory, agentType, defaultModel, expanded } = body; + const { + name, + workingDirectory, + agentType, + defaultModel, + initialPrompt, + expanded, + } = body; // Handle expanded toggle separately if (typeof expanded === "boolean") { @@ -43,12 +50,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } // Update other fields if provided - if (name || workingDirectory || agentType || defaultModel) { + if ( + name || + workingDirectory || + agentType || + defaultModel || + initialPrompt !== undefined + ) { const project = updateProject(id, { name, working_directory: workingDirectory, agent_type: agentType, default_model: defaultModel, + initial_prompt: initialPrompt, }); if (!project) { diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 9bc4554..3b6fe02 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -6,6 +6,7 @@ import { createWorktree } from "@/lib/worktrees"; import { setupWorktree, type SetupResult } from "@/lib/env-setup"; import { findAvailablePort } from "@/lib/ports"; import { runInBackground } from "@/lib/async-operations"; +import { getProject } from "@/lib/projects"; // GET /api/sessions - List all sessions and groups export async function GET() { @@ -181,6 +182,21 @@ export async function POST(request: NextRequest) { const session = queries.getSession(db).get(id) as Session; + // Get project's initial prompt if available + const project = projectId ? getProject(projectId) : null; + const projectInitialPrompt = project?.initial_prompt?.trim(); + const sessionInitialPrompt = initialPrompt?.trim(); + + // Combine prompts: project prompt first, then session prompt + let combinedPrompt: string | undefined; + if (projectInitialPrompt && sessionInitialPrompt) { + combinedPrompt = `${projectInitialPrompt}\n\n${sessionInitialPrompt}`; + } else if (projectInitialPrompt) { + combinedPrompt = projectInitialPrompt; + } else if (sessionInitialPrompt) { + combinedPrompt = sessionInitialPrompt; + } + // Include setup result and initial prompt in response const response: { session: Session; @@ -190,8 +206,8 @@ export async function POST(request: NextRequest) { if (setupResult) { response.setup = setupResult; } - if (initialPrompt?.trim()) { - response.initialPrompt = initialPrompt.trim(); + if (combinedPrompt) { + response.initialPrompt = combinedPrompt; } return NextResponse.json(response, { status: 201 }); diff --git a/components/FolderPicker.tsx b/components/FolderPicker.tsx index a175b86..46e9612 100644 --- a/components/FolderPicker.tsx +++ b/components/FolderPicker.tsx @@ -113,7 +113,7 @@ export function FolderPicker({ : files; return ( -
+
{/* Header */}
+ ); + })}
- - ))} + )); + })()}
diff --git a/components/GitDrawer/index.tsx b/components/GitDrawer/index.tsx index 7909c1b..042fca6 100644 --- a/components/GitDrawer/index.tsx +++ b/components/GitDrawer/index.tsx @@ -34,59 +34,143 @@ import { useCreatePR, useStageFiles, useUnstageFiles, + useMultiRepoGitStatus, gitKeys, } from "@/data/git/queries"; import type { GitFile } from "@/lib/git-status"; +import type { MultiRepoGitFile } from "@/lib/multi-repo-git"; +import type { ProjectRepository } from "@/lib/db"; interface GitDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; workingDirectory: string; + projectId?: string; + repositories?: ProjectRepository[]; } export function GitDrawer({ open, onOpenChange, workingDirectory, + projectId, + repositories = [], }: GitDrawerProps) { const queryClient = useQueryClient(); - // React Query hooks - only poll when drawer is open - const { - data: status, - isPending: loading, - isError, - error, - refetch: refetchStatus, - isRefetching, - } = useGitStatus(workingDirectory, { enabled: open }); + // Determine if we're in multi-repo mode + const isMultiRepo = repositories.length > 0; - const { data: prData } = usePRStatus(workingDirectory); + // Single-repo mode hooks - only poll when drawer is open + const singleRepoQuery = useGitStatus(workingDirectory, { + enabled: open && !isMultiRepo, + }); + + // Multi-repo mode hooks + const multiRepoQuery = useMultiRepoGitStatus(projectId, workingDirectory, { + enabled: open && isMultiRepo, + }); + + // Unified status based on mode + const loading = isMultiRepo + ? multiRepoQuery.isPending + : singleRepoQuery.isPending; + const isError = isMultiRepo + ? multiRepoQuery.isError + : singleRepoQuery.isError; + const error = isMultiRepo ? multiRepoQuery.error : singleRepoQuery.error; + const isRefetching = isMultiRepo + ? multiRepoQuery.isRefetching + : singleRepoQuery.isRefetching; + + // Convert to unified status + const status = isMultiRepo + ? multiRepoQuery.data + ? { + branch: + multiRepoQuery.data.repositories.length === 1 + ? multiRepoQuery.data.repositories[0]?.branch || "" + : `${multiRepoQuery.data.repositories.length} repos`, + ahead: multiRepoQuery.data.repositories.reduce( + (sum, r) => sum + r.ahead, + 0 + ), + behind: multiRepoQuery.data.repositories.reduce( + (sum, r) => sum + r.behind, + 0 + ), + staged: multiRepoQuery.data.staged, + unstaged: multiRepoQuery.data.unstaged, + untracked: multiRepoQuery.data.untracked, + } + : null + : singleRepoQuery.data || null; + + const refetchStatus = isMultiRepo + ? multiRepoQuery.refetch + : singleRepoQuery.refetch; + + // For PR status, use the primary repo or first repo in multi-repo mode + const primaryRepoPath = isMultiRepo + ? repositories.find((r) => r.is_primary)?.path || + repositories[0]?.path || + workingDirectory + : workingDirectory; + + const { data: prData } = usePRStatus(primaryRepoPath); const existingPR = prData?.existingPR ?? null; - const createPRMutation = useCreatePR(workingDirectory); - const stageMutation = useStageFiles(workingDirectory); - const unstageMutation = useUnstageFiles(workingDirectory); + const createPRMutation = useCreatePR(primaryRepoPath); + const stageMutation = useStageFiles(primaryRepoPath); + const unstageMutation = useUnstageFiles(primaryRepoPath); // Local UI state - const [selectedFile, setSelectedFile] = useState(null); - const [discardFile, setDiscardFile] = useState(null); + const [selectedFile, setSelectedFile] = useState< + GitFile | MultiRepoGitFile | null + >(null); + const [discardFile, setDiscardFile] = useState< + GitFile | MultiRepoGitFile | null + >(null); const [discarding, setDiscarding] = useState(false); // Animation const isAnimatingIn = useDrawerAnimation(open); // Clear selected file when drawer opens - const handleFileClick = (file: GitFile) => { + const handleFileClick = (file: GitFile | MultiRepoGitFile) => { setSelectedFile(file); }; - const handleStage = (file: GitFile) => { - stageMutation.mutate([file.path]); + const handleStage = async (file: GitFile | MultiRepoGitFile) => { + // In multi-repo mode, use the file's repoPath + const repoPath = + "repoPath" in file && file.repoPath ? file.repoPath : primaryRepoPath; + try { + await fetch("/api/git/stage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: repoPath, files: [file.path] }), + }); + queryClient.invalidateQueries({ queryKey: gitKeys.all }); + } catch { + // Ignore errors + } }; - const handleUnstage = (file: GitFile) => { - unstageMutation.mutate([file.path]); + const handleUnstage = async (file: GitFile | MultiRepoGitFile) => { + // In multi-repo mode, use the file's repoPath + const repoPath = + "repoPath" in file && file.repoPath ? file.repoPath : primaryRepoPath; + try { + await fetch("/api/git/unstage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: repoPath, files: [file.path] }), + }); + queryClient.invalidateQueries({ queryKey: gitKeys.all }); + } catch { + // Ignore errors + } }; const handleStageAll = () => { @@ -102,17 +186,20 @@ export function GitDrawer({ setDiscarding(true); try { + // In multi-repo mode, use the file's repoPath + const repoPath = + "repoPath" in discardFile && discardFile.repoPath + ? discardFile.repoPath + : primaryRepoPath; await fetch("/api/git/discard", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - path: workingDirectory, + path: repoPath, file: discardFile.path, }), }); - queryClient.invalidateQueries({ - queryKey: gitKeys.status(workingDirectory), - }); + queryClient.invalidateQueries({ queryKey: gitKeys.all }); setDiscardFile(null); } catch { // Ignore errors @@ -256,6 +343,7 @@ export function GitDrawer({ onUnstage={handleUnstage} onUnstageAll={handleUnstageAll} isStaged={true} + groupByRepo={isMultiRepo} /> {/* Unstaged files */} @@ -268,6 +356,7 @@ export function GitDrawer({ onStageAll={handleStageAll} onDiscard={setDiscardFile} isStaged={false} + groupByRepo={isMultiRepo} /> )} @@ -276,17 +365,12 @@ export function GitDrawer({ {/* Commit form at bottom */} {status && ( { - queryClient.invalidateQueries({ - queryKey: gitKeys.status(workingDirectory), - }); - queryClient.invalidateQueries({ - queryKey: gitKeys.pr(workingDirectory), - }); + queryClient.invalidateQueries({ queryKey: gitKeys.all }); }} /> )} diff --git a/components/GitPanel/FileChanges.tsx b/components/GitPanel/FileChanges.tsx index 6df033a..16130ac 100644 --- a/components/GitPanel/FileChanges.tsx +++ b/components/GitPanel/FileChanges.tsx @@ -20,19 +20,23 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { GitFile } from "@/lib/git-status"; +import type { MultiRepoGitFile } from "@/lib/multi-repo-git"; + +type AnyGitFile = GitFile | MultiRepoGitFile; interface FileChangesProps { - files: GitFile[]; + files: AnyGitFile[]; title: string; emptyMessage: string; selectedPath?: string; - onFileClick: (file: GitFile) => void; - onStage?: (file: GitFile) => void; - onUnstage?: (file: GitFile) => void; + onFileClick: (file: AnyGitFile) => void; + onStage?: (file: AnyGitFile) => void; + onUnstage?: (file: AnyGitFile) => void; onStageAll?: () => void; onUnstageAll?: () => void; - onDiscard?: (file: GitFile) => void; + onDiscard?: (file: AnyGitFile) => void; isStaged?: boolean; + groupByRepo?: boolean; } const SWIPE_THRESHOLD = 80; @@ -49,6 +53,7 @@ export function FileChanges({ onUnstageAll, onDiscard, isStaged = false, + groupByRepo = false, }: FileChangesProps) { const [expanded, setExpanded] = useState(true); @@ -58,6 +63,20 @@ export function FileChanges({ const showAllButton = files.length > 1 && (onStageAll || onUnstageAll); + // Group files by repo if enabled + const groupedFiles = groupByRepo + ? (() => { + const grouped = new Map(); + for (const f of files) { + const repoKey = "repoName" in f && f.repoName ? f.repoName : ""; + const existing = grouped.get(repoKey) || []; + existing.push(f); + grouped.set(repoKey, existing); + } + return Array.from(grouped.entries()); + })() + : [["", files] as [string, AnyGitFile[]]]; + return (
@@ -96,19 +115,34 @@ export function FileChanges({ {expanded && (
- {files.map((file) => ( - onFileClick(file)} - onStage={onStage ? () => onStage(file) : undefined} - onUnstage={onUnstage ? () => onUnstage(file) : undefined} - onDiscard={onDiscard ? () => onDiscard(file) : undefined} - onSwipeLeft={isStaged ? () => onUnstage?.(file) : undefined} - onSwipeRight={!isStaged ? () => onStage?.(file) : undefined} - isStaged={isStaged} - /> + {groupedFiles.map(([repoName, repoFiles]) => ( +
+ {repoName && ( +
+ {repoName} +
+ )} + {repoFiles.map((file) => { + const fileKey = + "repoPath" in file + ? `${file.repoPath}-${file.path}` + : file.path; + return ( + onFileClick(file)} + onStage={onStage ? () => onStage(file) : undefined} + onUnstage={onUnstage ? () => onUnstage(file) : undefined} + onDiscard={onDiscard ? () => onDiscard(file) : undefined} + onSwipeLeft={isStaged ? () => onUnstage?.(file) : undefined} + onSwipeRight={!isStaged ? () => onStage?.(file) : undefined} + isStaged={isStaged} + /> + ); + })} +
))}
)} @@ -117,7 +151,7 @@ export function FileChanges({ } interface FileItemProps { - file: GitFile; + file: AnyGitFile; isSelected?: boolean; onClick: () => void; onStage?: () => void; diff --git a/components/GitPanel/index.tsx b/components/GitPanel/index.tsx index fb87f5c..eb96368 100644 --- a/components/GitPanel/index.tsx +++ b/components/GitPanel/index.tsx @@ -30,42 +30,102 @@ import { useCreatePR, useStageFiles, useUnstageFiles, + useMultiRepoGitStatus, gitKeys, } from "@/data/git/queries"; import type { GitStatus, GitFile } from "@/lib/git-status"; +import type { MultiRepoGitFile } from "@/lib/multi-repo-git"; +import type { ProjectRepository } from "@/lib/db"; interface GitPanelProps { workingDirectory: string; + projectId?: string; + repositories?: ProjectRepository[]; onFileSelect?: (file: GitFile, diff: string) => void; } interface SelectedFile { - file: GitFile; + file: GitFile | MultiRepoGitFile; diff: string; + repoPath?: string; } -export function GitPanel({ workingDirectory }: GitPanelProps) { +export function GitPanel({ + workingDirectory, + projectId, + repositories = [], +}: GitPanelProps) { const { isMobile } = useViewport(); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState("changes"); const [showPRModal, setShowPRModal] = useState(false); - // React Query hooks - const { - data: status, - isPending: loading, - isError, - error, - refetch: refetchStatus, - isRefetching, - } = useGitStatus(workingDirectory); - - const { data: prData } = usePRStatus(workingDirectory); + // Determine if we're in multi-repo mode + const isMultiRepo = repositories.length > 0; + + // Single-repo mode hooks + const singleRepoQuery = useGitStatus(workingDirectory, { + enabled: !isMultiRepo, + }); + + // Multi-repo mode hooks + const multiRepoQuery = useMultiRepoGitStatus(projectId, workingDirectory, { + enabled: isMultiRepo, + }); + + // Unified status based on mode + const loading = isMultiRepo + ? multiRepoQuery.isPending + : singleRepoQuery.isPending; + const isError = isMultiRepo + ? multiRepoQuery.isError + : singleRepoQuery.isError; + const error = isMultiRepo ? multiRepoQuery.error : singleRepoQuery.error; + const isRefetching = isMultiRepo + ? multiRepoQuery.isRefetching + : singleRepoQuery.isRefetching; + + // Convert multi-repo status to single-repo-like status for unified handling + const status: GitStatus | null = isMultiRepo + ? multiRepoQuery.data + ? { + // Use first repo's branch or "Multiple" + branch: + multiRepoQuery.data.repositories.length === 1 + ? multiRepoQuery.data.repositories[0]?.branch || "" + : `${multiRepoQuery.data.repositories.length} repos`, + ahead: multiRepoQuery.data.repositories.reduce( + (sum, r) => sum + r.ahead, + 0 + ), + behind: multiRepoQuery.data.repositories.reduce( + (sum, r) => sum + r.behind, + 0 + ), + staged: multiRepoQuery.data.staged, + unstaged: multiRepoQuery.data.unstaged, + untracked: multiRepoQuery.data.untracked, + } + : null + : singleRepoQuery.data || null; + + const refetchStatus = isMultiRepo + ? multiRepoQuery.refetch + : singleRepoQuery.refetch; + + // For PR status, use the primary repo or first repo in multi-repo mode + const primaryRepoPath = isMultiRepo + ? repositories.find((r) => r.is_primary)?.path || + repositories[0]?.path || + workingDirectory + : workingDirectory; + + const { data: prData } = usePRStatus(primaryRepoPath); const existingPR = prData?.existingPR ?? null; - const createPRMutation = useCreatePR(workingDirectory); - const stageMutation = useStageFiles(workingDirectory); - const unstageMutation = useUnstageFiles(workingDirectory); + const createPRMutation = useCreatePR(primaryRepoPath); + const stageMutation = useStageFiles(primaryRepoPath); + const unstageMutation = useUnstageFiles(primaryRepoPath); // Selected file for diff view const [selectedFile, setSelectedFile] = useState(null); @@ -80,12 +140,15 @@ export function GitPanel({ workingDirectory }: GitPanelProps) { await refetchStatus(); }; - const handleFileClick = async (file: GitFile) => { + const handleFileClick = async (file: GitFile | MultiRepoGitFile) => { setLoadingDiff(true); try { const isUntracked = file.status === "untracked"; + // In multi-repo mode, use the file's repoPath + const repoPath = + "repoPath" in file && file.repoPath ? file.repoPath : workingDirectory; const params = new URLSearchParams({ - path: workingDirectory, + path: repoPath, file: file.path, staged: file.staged.toString(), ...(isUntracked && { untracked: "true" }), @@ -95,7 +158,7 @@ export function GitPanel({ workingDirectory }: GitPanelProps) { const data = await res.json(); if (data.diff !== undefined) { - setSelectedFile({ file, diff: data.diff }); + setSelectedFile({ file, diff: data.diff, repoPath }); } } catch { // Ignore errors @@ -104,29 +167,49 @@ export function GitPanel({ workingDirectory }: GitPanelProps) { } }; - const handleStage = (file: GitFile) => { - stageMutation.mutate([file.path], { - onSuccess: () => { - // Update selected file's staged status if it's the same file - if (selectedFile?.file.path === file.path) { - setSelectedFile({ ...selectedFile, file: { ...file, staged: true } }); - } - }, - }); + const handleStage = async (file: GitFile | MultiRepoGitFile) => { + // In multi-repo mode, use the file's repoPath + const repoPath = + "repoPath" in file && file.repoPath ? file.repoPath : primaryRepoPath; + try { + await fetch("/api/git/stage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: repoPath, files: [file.path] }), + }); + // Invalidate queries to refresh + queryClient.invalidateQueries({ queryKey: gitKeys.all }); + // Update selected file's staged status if it's the same file + if (selectedFile?.file.path === file.path) { + setSelectedFile({ ...selectedFile, file: { ...file, staged: true } }); + } + } catch { + // Ignore errors + } }; - const handleUnstage = (file: GitFile) => { - unstageMutation.mutate([file.path], { - onSuccess: () => { - // Update selected file's staged status if it's the same file - if (selectedFile?.file.path === file.path) { - setSelectedFile({ - ...selectedFile, - file: { ...file, staged: false }, - }); - } - }, - }); + const handleUnstage = async (file: GitFile | MultiRepoGitFile) => { + // In multi-repo mode, use the file's repoPath + const repoPath = + "repoPath" in file && file.repoPath ? file.repoPath : primaryRepoPath; + try { + await fetch("/api/git/unstage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: repoPath, files: [file.path] }), + }); + // Invalidate queries to refresh + queryClient.invalidateQueries({ queryKey: gitKeys.all }); + // Update selected file's staged status if it's the same file + if (selectedFile?.file.path === file.path) { + setSelectedFile({ + ...selectedFile, + file: { ...file, staged: false }, + }); + } + } catch { + // Ignore errors + } }; const handleStageAll = () => { diff --git a/components/Pane/index.tsx b/components/Pane/index.tsx index 68adbae..79307a4 100644 --- a/components/Pane/index.tsx +++ b/components/Pane/index.tsx @@ -121,6 +121,19 @@ export const Pane = memo(function Pane({ const isConductor = workerCount > 0; + // Get current project and its repositories + const currentProject = useMemo(() => { + if (!session?.project_id) return null; + return projects.find((p) => p.id === session.project_id) || null; + }, [session?.project_id, projects]); + + // Type assertion for repositories (projects passed here should have repositories) + const projectRepositories = useMemo(() => { + if (!currentProject) return []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (currentProject as any).repositories || []; + }, [currentProject]); + // Watch for file open requests const { request: fileOpenRequest } = useSnapshot(fileOpenStore); @@ -359,7 +372,11 @@ export const Pane = memo(function Pane({ {/* Git - mobile only */} {session?.working_directory && (
- +
)} @@ -500,6 +517,8 @@ export const Pane = memo(function Pane({ open={true} onOpenChange={setGitDrawerOpen} workingDirectory={session.working_directory} + projectId={currentProject?.id} + repositories={projectRepositories} /> diff --git a/components/Projects/ProjectSettingsDialog.tsx b/components/Projects/ProjectSettingsDialog.tsx index 087b98b..6e76369 100644 --- a/components/Projects/ProjectSettingsDialog.tsx +++ b/components/Projects/ProjectSettingsDialog.tsx @@ -10,6 +10,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, @@ -17,12 +18,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Plus, Trash2, Loader2, RefreshCw, Server } from "lucide-react"; +import { + Plus, + Trash2, + Loader2, + RefreshCw, + Server, + GitBranch, + Star, + FolderOpen, +} from "lucide-react"; +import { FolderPicker } from "@/components/FolderPicker"; import { useUpdateProject } from "@/data/projects"; import { useQueryClient } from "@tanstack/react-query"; import { devServerKeys } from "@/data/dev-servers"; +import { repositoryKeys } from "@/data/repositories"; import type { AgentType } from "@/lib/providers"; -import type { ProjectWithDevServers, DetectedDevServer } from "@/lib/projects"; +import type { + ProjectWithRepositories, + DetectedDevServer, +} from "@/lib/projects"; const AGENT_OPTIONS: { value: AgentType; label: string }[] = [ { value: "claude", label: "Claude Code" }, @@ -50,8 +65,17 @@ interface DevServerConfig { isDeleted?: boolean; } +interface RepositoryConfig { + id: string; + name: string; + path: string; + isPrimary: boolean; + isNew?: boolean; + isDeleted?: boolean; +} + interface ProjectSettingsDialogProps { - project: ProjectWithDevServers | null; + project: ProjectWithRepositories | null; open: boolean; onClose: () => void; onSave: () => void; @@ -67,10 +91,16 @@ export function ProjectSettingsDialog({ const [workingDirectory, setWorkingDirectory] = useState(""); const [agentType, setAgentType] = useState("claude"); const [defaultModel, setDefaultModel] = useState("sonnet"); + const [initialPrompt, setInitialPrompt] = useState(""); const [devServers, setDevServers] = useState([]); + const [repositories, setRepositories] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isDetecting, setIsDetecting] = useState(false); + const [isDetectingRepos, setIsDetectingRepos] = useState(false); const [error, setError] = useState(null); + const [folderPickerRepoId, setFolderPickerRepoId] = useState( + null + ); const updateProject = useUpdateProject(); const queryClient = useQueryClient(); @@ -82,6 +112,7 @@ export function ProjectSettingsDialog({ setWorkingDirectory(project.working_directory); setAgentType(project.agent_type); setDefaultModel(project.default_model); + setInitialPrompt(project.initial_prompt || ""); setDevServers( project.devServers.map((ds) => ({ id: ds.id, @@ -92,9 +123,26 @@ export function ProjectSettingsDialog({ portEnvVar: ds.port_env_var || undefined, })) ); + setRepositories( + (project.repositories || []).map((repo) => ({ + id: repo.id, + name: repo.name, + path: repo.path, + isPrimary: repo.is_primary, + })) + ); + // Reset folder picker state when project changes + setFolderPickerRepoId(null); } }, [project]); + // Reset folder picker when dialog closes + useEffect(() => { + if (!open) { + setFolderPickerRepoId(null); + } + }, [open]); + // Detect dev servers const detectDevServers = async () => { if (!workingDirectory) return; @@ -171,6 +219,92 @@ export function ProjectSettingsDialog({ ); }; + // Detect git repositories in working directory + const detectRepositories = async () => { + if (!workingDirectory) return; + + setIsDetectingRepos(true); + try { + // Check if the working directory itself is a git repo + const res = await fetch( + `/api/git/status?path=${encodeURIComponent(workingDirectory)}` + ); + if (res.ok) { + const existingPaths = new Set(repositories.map((r) => r.path)); + if (!existingPaths.has(workingDirectory)) { + // Extract repo name from path + const pathParts = workingDirectory.split("/").filter(Boolean); + const repoName = pathParts[pathParts.length - 1] || "Repository"; + + setRepositories((prev) => [ + ...prev, + { + id: `new_${Date.now()}`, + name: repoName, + path: workingDirectory, + isPrimary: prev.length === 0, + isNew: true, + }, + ]); + } + } + } catch { + // Not a git repo, that's okay + } finally { + setIsDetectingRepos(false); + } + }; + + // Add new repository config - opens folder picker directly + const addRepository = () => { + const newId = `new_${Date.now()}`; + setRepositories((prev) => [ + ...prev, + { + id: newId, + name: "", + path: "", + isPrimary: prev.filter((r) => !r.isDeleted).length === 0, + isNew: true, + }, + ]); + // Open folder picker for the new repository + setFolderPickerRepoId(newId); + }; + + // Remove repository config + const removeRepository = (id: string) => { + setRepositories( + (prev) => + prev + .map((repo) => + repo.id === id + ? repo.isNew + ? null // Remove new items completely + : { ...repo, isDeleted: true } // Mark existing for deletion + : repo + ) + .filter(Boolean) as RepositoryConfig[] + ); + }; + + // Update repository config + const updateRepository = (id: string, updates: Partial) => { + setRepositories((prev) => + prev.map((repo) => (repo.id === id ? { ...repo, ...updates } : repo)) + ); + }; + + // Set a repository as primary + const setRepositoryPrimary = (id: string) => { + setRepositories((prev) => + prev.map((repo) => ({ + ...repo, + isPrimary: repo.id === id, + })) + ); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!project) return; @@ -190,6 +324,7 @@ export function ProjectSettingsDialog({ workingDirectory, agentType, defaultModel, + initialPrompt: initialPrompt.trim() || null, }); // Handle dev server changes @@ -233,8 +368,49 @@ export function ProjectSettingsDialog({ } } + // Handle repository changes + for (const repo of repositories) { + if (repo.isDeleted && !repo.isNew) { + // Delete existing repository + await fetch(`/api/projects/${project.id}/repositories/${repo.id}`, { + method: "DELETE", + }); + } else if ( + repo.isNew && + !repo.isDeleted && + repo.name.trim() && + repo.path.trim() + ) { + // Create new repository + await fetch(`/api/projects/${project.id}/repositories`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: repo.name.trim(), + path: repo.path.trim(), + isPrimary: repo.isPrimary, + }), + }); + } else if (!repo.isNew && !repo.isDeleted) { + // Update existing repository + await fetch(`/api/projects/${project.id}/repositories/${repo.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: repo.name.trim(), + path: repo.path.trim(), + isPrimary: repo.isPrimary, + }), + }); + } + } + // Invalidate dev servers cache so list updates queryClient.invalidateQueries({ queryKey: devServerKeys.list() }); + // Invalidate repositories cache + queryClient.invalidateQueries({ + queryKey: repositoryKeys.list(project.id), + }); handleClose(); onSave(); @@ -248,209 +424,393 @@ export function ProjectSettingsDialog({ const handleClose = () => { setError(null); + setFolderPickerRepoId(null); onClose(); }; const visibleDevServers = devServers.filter((ds) => !ds.isDeleted); + const visibleRepositories = repositories.filter((repo) => !repo.isDeleted); if (!project) return null; return ( - !o && handleClose()}> - - - Project Settings - - -
- {/* Project Name */} -
- - setName(e.target.value)} - placeholder="my-awesome-project" - /> -
- - {/* Working Directory */} -
- - setWorkingDirectory(e.target.value)} - placeholder="~/projects/my-app" - /> -
- - {/* Agent Type */} -
- - -
- - {/* Default Model */} -
- - -
- - {/* Dev Servers */} -
-
- -
- - -
+ <> + !o && handleClose()} + > + + + Project Settings + + + + {/* Project Name */} +
+ + setName(e.target.value)} + placeholder="my-awesome-project" + /> +
+ + {/* Working Directory */} +
+ + setWorkingDirectory(e.target.value)} + placeholder="~/projects/my-app" + /> +
+ + {/* Agent Type */} +
+ + +
+ + {/* Default Model */} +
+ +
- {visibleDevServers.length === 0 ? ( -

- No dev servers configured. + {/* Initial Prompt */} +

+ +