From e371f790c7846de9cccd0f1de848f38cead9ee9a Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Fri, 16 Jan 2026 17:47:01 -0800 Subject: [PATCH 1/4] Add clone from GitHub feature to project selector Adds ability to clone repos directly from GitHub by entering a URL or owner/repo format. Repos are stored in userData/repos/{owner}/{repo}. Co-Authored-By: Claude Opus 4.5 --- src/main/lib/trpc/routers/projects.ts | 126 +++++++++++++++++- .../agents/components/project-selector.tsx | 104 ++++++++++++++- 2 files changed, 227 insertions(+), 3 deletions(-) diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index 0f966824d..c41b8ca81 100644 --- a/src/main/lib/trpc/routers/projects.ts +++ b/src/main/lib/trpc/routers/projects.ts @@ -2,11 +2,17 @@ import { z } from "zod" import { router, publicProcedure } from "../index" import { getDatabase, projects } from "../../db" import { eq, desc } from "drizzle-orm" -import { dialog, BrowserWindow } from "electron" -import { basename } from "path" +import { dialog, BrowserWindow, app } from "electron" +import { basename, join } from "path" +import { exec } from "node:child_process" +import { promisify } from "node:util" +import { existsSync } from "node:fs" +import { mkdir } from "node:fs/promises" import { getGitRemoteInfo } from "../../git" import { trackProjectOpened } from "../../analytics" +const execAsync = promisify(exec) + export const projectsRouter = router({ /** * List all projects @@ -219,4 +225,120 @@ export const projectsRouter = router({ .returning() .get() }), + + /** + * Clone a GitHub repo and create a project + */ + cloneFromGitHub: publicProcedure + .input(z.object({ repoUrl: z.string() })) + .mutation(async ({ input }) => { + const { repoUrl } = input + + // Parse the URL to extract owner/repo + let owner: string | null = null + let repo: string | null = null + + // Match HTTPS format: https://github.com/owner/repo + const httpsMatch = repoUrl.match( + /https?:\/\/github\.com\/([^/]+)\/([^/]+)/, + ) + if (httpsMatch) { + owner = httpsMatch[1] || null + repo = httpsMatch[2]?.replace(/\.git$/, "") || null + } + + // Match SSH format: git@github.com:owner/repo + const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+)/) + if (sshMatch) { + owner = sshMatch[1] || null + repo = sshMatch[2]?.replace(/\.git$/, "") || null + } + + // Match short format: owner/repo + const shortMatch = repoUrl.match(/^([^/]+)\/([^/]+)$/) + if (shortMatch) { + owner = shortMatch[1] || null + repo = shortMatch[2]?.replace(/\.git$/, "") || null + } + + if (!owner || !repo) { + throw new Error("Invalid GitHub URL or repo format") + } + + // Clone to userData/repos/{owner}/{repo} + const userDataPath = app.getPath("userData") + const reposDir = join(userDataPath, "repos", owner) + const clonePath = join(reposDir, repo) + + // Check if already cloned + if (existsSync(clonePath)) { + // Project might already exist in DB + const db = getDatabase() + const existing = db + .select() + .from(projects) + .where(eq(projects.path, clonePath)) + .get() + + if (existing) { + trackProjectOpened({ + id: existing.id, + hasGitRemote: !!existing.gitRemoteUrl, + }) + return existing + } + + // Create project for existing clone + const gitInfo = await getGitRemoteInfo(clonePath) + const newProject = db + .insert(projects) + .values({ + name: repo, + path: clonePath, + gitRemoteUrl: gitInfo.remoteUrl, + gitProvider: gitInfo.provider, + gitOwner: gitInfo.owner, + gitRepo: gitInfo.repo, + }) + .returning() + .get() + + trackProjectOpened({ + id: newProject!.id, + hasGitRemote: !!gitInfo.remoteUrl, + }) + return newProject + } + + // Create repos directory + await mkdir(reposDir, { recursive: true }) + + // Clone the repo + const cloneUrl = `https://github.com/${owner}/${repo}.git` + await execAsync(`git clone "${cloneUrl}" "${clonePath}"`) + + // Get git info and create project + const db = getDatabase() + const gitInfo = await getGitRemoteInfo(clonePath) + + const newProject = db + .insert(projects) + .values({ + name: repo, + path: clonePath, + gitRemoteUrl: gitInfo.remoteUrl, + gitProvider: gitInfo.provider, + gitOwner: gitInfo.owner, + gitRepo: gitInfo.repo, + }) + .returning() + .get() + + trackProjectOpened({ + id: newProject!.id, + hasGitRemote: !!gitInfo.remoteUrl, + }) + + return newProject + }), }) diff --git a/src/renderer/features/agents/components/project-selector.tsx b/src/renderer/features/agents/components/project-selector.tsx index b8ef88073..1b3b1f311 100644 --- a/src/renderer/features/agents/components/project-selector.tsx +++ b/src/renderer/features/agents/components/project-selector.tsx @@ -14,7 +14,15 @@ import { CommandItem, CommandList, } from "../../../components/ui/command" -import { IconChevronDown, CheckIcon, FolderPlusIcon } from "../../../components/ui/icons" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "../../../components/ui/dialog" +import { Input } from "../../../components/ui/input" +import { IconChevronDown, CheckIcon, FolderPlusIcon, GitHubIcon } from "../../../components/ui/icons" import { trpc } from "../../../lib/trpc" import { selectedProjectAtom } from "../atoms" @@ -48,6 +56,8 @@ export function ProjectSelector() { const [selectedProject, setSelectedProject] = useAtom(selectedProjectAtom) const [open, setOpen] = useState(false) const [searchQuery, setSearchQuery] = useState("") + const [githubDialogOpen, setGithubDialogOpen] = useState(false) + const [githubUrl, setGithubUrl] = useState("") // Fetch projects from DB const { data: projects, isLoading: isLoadingProjects } = trpc.projects.list.useQuery() @@ -100,11 +110,50 @@ export function ProjectSelector() { }, }) + // Clone from GitHub mutation + const cloneFromGitHub = trpc.projects.cloneFromGitHub.useMutation({ + onSuccess: (project) => { + if (project) { + utils.projects.list.setData(undefined, (oldData) => { + if (!oldData) return [project] + const exists = oldData.some((p) => p.id === project.id) + if (exists) { + return oldData.map((p) => + p.id === project.id ? { ...p, updatedAt: project.updatedAt } : p, + ) + } + return [project, ...oldData] + }) + + setSelectedProject({ + id: project.id, + name: project.name, + path: project.path, + gitRemoteUrl: project.gitRemoteUrl, + gitProvider: project.gitProvider as + | "github" + | "gitlab" + | "bitbucket" + | null, + gitOwner: project.gitOwner, + gitRepo: project.gitRepo, + }) + setGithubDialogOpen(false) + setGithubUrl("") + } + }, + }) + const handleOpenFolder = async () => { setOpen(false) await openFolder.mutateAsync() } + const handleCloneFromGitHub = async () => { + if (!githubUrl.trim()) return + await cloneFromGitHub.mutateAsync({ repoUrl: githubUrl.trim() }) + } + const handleSelectProject = (projectId: string) => { const project = projects?.find((p) => p.id === projectId) if (project) { @@ -152,6 +201,7 @@ export function ProjectSelector() { } return ( + <> { @@ -222,9 +272,61 @@ export function ProjectSelector() { {openFolder.isPending ? "Adding..." : "Add repository"} + + + + + + Clone from GitHub + + Enter a GitHub repository URL or owner/repo + + +
{ + e.preventDefault() + handleCloneFromGitHub() + }} + className="flex flex-col gap-4" + > + setGithubUrl(e.target.value)} + autoFocus + /> +
+ + +
+
+
+
+ ) } From 2495db682c1606a1f02b523e95a6104cfd709f2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:48:19 +0000 Subject: [PATCH 2/4] Initial plan From 786fa37a0becc5ce413a90d27808ca71dcf6b554 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:51:40 +0000 Subject: [PATCH 3/4] Add Blender script with frames_dir and fps parameters Co-authored-by: jz24q6bct9-ops <247274528+jz24q6bct9-ops@users.noreply.github.com> --- main.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 000000000..a96ac71bc --- /dev/null +++ b/main.py @@ -0,0 +1,119 @@ +""" +Blender script to process frames from a directory. + +Usage: + blender --background --python main.py -- --frames_dir path --fps 30 +""" + +import sys +import argparse +from pathlib import Path + + +def parse_arguments(): + """ + Parse command-line arguments. + + When running with Blender, arguments after '--' are passed to the script. + """ + # Find the separator '--' in sys.argv + if '--' in sys.argv: + # Get arguments after the '--' separator + script_args = sys.argv[sys.argv.index('--') + 1:] + else: + # No separator found, use all arguments after script name + script_args = sys.argv[1:] + + parser = argparse.ArgumentParser( + description='Process frames in Blender', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument( + '--frames_dir', + type=str, + required=True, + help='Directory containing frames to process' + ) + parser.add_argument( + '--fps', + type=int, + default=30, + help='Frames per second (default: 30)' + ) + + args = parser.parse_args(script_args) + return args + + +def process_frames(frames_dir, fps): + """ + Process frames from the specified directory. + + Args: + frames_dir: Path to directory containing frames + fps: Frames per second for rendering + """ + frames_path = Path(frames_dir) + + if not frames_path.exists(): + raise FileNotFoundError(f"Frames directory not found: {frames_dir}") + + if not frames_path.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {frames_dir}") + + print(f"Processing frames from: {frames_path.absolute()}") + print(f"FPS: {fps}") + + # Import Blender API (only available when running within Blender) + try: + import bpy + + # Set render FPS + bpy.context.scene.render.fps = fps + print(f"Blender scene FPS set to: {fps}") + + # Get list of frame files + frame_files = sorted([ + f for f in frames_path.iterdir() + if f.is_file() and f.suffix.lower() in {'.png', '.jpg', '.jpeg', '.exr', '.tiff', '.tif'} + ]) + + if not frame_files: + print(f"Warning: No image files found in {frames_dir}") + return + + print(f"Found {len(frame_files)} frame files") + + # Process frames + for idx, frame_file in enumerate(frame_files, start=1): + print(f"Processing frame {idx}/{len(frame_files)}: {frame_file.name}") + # Add custom processing logic here + + print("Frame processing complete!") + + except ImportError: + print("Warning: Blender Python API (bpy) not available.") + print("This script should be run with: blender --background --python main.py -- --frames_dir path --fps 30") + print(f"\nDry run mode:") + print(f" Frames directory: {frames_path.absolute()}") + print(f" FPS: {fps}") + + # List available frames even without Blender + frame_files = list(frames_path.glob('*.*')) + if frame_files: + print(f" Found {len(frame_files)} files in directory") + + +def main(): + """Main entry point.""" + try: + args = parse_arguments() + process_frames(args.frames_dir, args.fps) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() From 1869a7ecb5df97ae70dfe6461cdc131a46bc9d12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:52:31 +0000 Subject: [PATCH 4/4] Improve code quality: add constants and consistent filtering Co-authored-by: jz24q6bct9-ops <247274528+jz24q6bct9-ops@users.noreply.github.com> --- main.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index a96ac71bc..13e376a59 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,9 @@ import argparse from pathlib import Path +# Supported image file extensions +SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.exr', '.tiff', '.tif'} + def parse_arguments(): """ @@ -76,7 +79,7 @@ def process_frames(frames_dir, fps): # Get list of frame files frame_files = sorted([ f for f in frames_path.iterdir() - if f.is_file() and f.suffix.lower() in {'.png', '.jpg', '.jpeg', '.exr', '.tiff', '.tif'} + if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS ]) if not frame_files: @@ -99,10 +102,11 @@ def process_frames(frames_dir, fps): print(f" Frames directory: {frames_path.absolute()}") print(f" FPS: {fps}") - # List available frames even without Blender - frame_files = list(frames_path.glob('*.*')) + # List available frames even without Blender (filter by supported extensions) + frame_files = [f for f in frames_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS] if frame_files: - print(f" Found {len(frame_files)} files in directory") + print(f" Found {len(frame_files)} image files in directory") + print(f" Supported formats: {', '.join(sorted(SUPPORTED_EXTENSIONS))}") def main():