diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7cf0b32..1509152 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["welcome", "folder-*", "commit-*", "merge-*", "stash-*", "push-*", "settings"], + "windows": ["welcome", "folder-*", "commit-*", "merge-*", "stash-*", "push-*", "settings", "project-boot"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 4eb080b..d42ecf2 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -9,7 +9,8 @@ "welcome", "folder-*", "commit-*", - "settings" + "settings", + "project-boot" ], "permissions": [ "window-state:default" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e53c4bb..c230ac6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod conversations; pub mod folder_commands; pub mod folders; pub mod mcp; +pub mod project_boot; pub mod system_settings; pub mod terminal; pub mod version_control; diff --git a/src-tauri/src/commands/project_boot.rs b/src-tauri/src/commands/project_boot.rs new file mode 100644 index 0000000..a785676 --- /dev/null +++ b/src-tauri/src/commands/project_boot.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; + +use crate::app_error::AppCommandError; + +#[tauri::command] +pub async fn create_shadcn_project( + project_name: String, + template: String, + preset_code: String, + package_manager: String, + target_dir: String, +) -> Result { + let project_name = project_name.trim().to_string(); + let template = template.trim().to_string(); + let preset_code = preset_code.trim().to_string(); + let package_manager = package_manager.trim().to_string(); + let target_dir = target_dir.trim().to_string(); + + if project_name.is_empty() { + return Err(AppCommandError::invalid_input("Project name is required")); + } + if template.is_empty() { + return Err(AppCommandError::invalid_input("Template is required")); + } + if target_dir.is_empty() { + return Err(AppCommandError::invalid_input("Target directory is required")); + } + + let full_path = PathBuf::from(&target_dir).join(&project_name); + let full_path_str = full_path.to_string_lossy().to_string(); + + // Check if directory already exists and is non-empty + if full_path.exists() { + let is_empty = full_path + .read_dir() + .map(|mut entries| entries.next().is_none()) + .unwrap_or(false); + if !is_empty { + return Err(AppCommandError::already_exists( + "Target directory already exists and is not empty", + )); + } + } + + // Determine the command based on package manager + let (program, prefix_args): (&str, Vec<&str>) = match package_manager.as_str() { + "pnpm" => ("pnpm", vec!["dlx"]), + "yarn" => ("yarn", vec!["dlx"]), + "bun" => ("bunx", vec![]), + _ => ("npx", vec![]), + }; + + let mut cmd = crate::process::tokio_command(program); + cmd.args(&prefix_args); + cmd.args([ + "shadcn@latest", + "init", + "-n", + &project_name, + "-t", + &template, + "-p", + &preset_code, + "-y", + ]); + cmd.current_dir(&target_dir); + + let output = cmd.output().await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + AppCommandError::dependency_missing(format!( + "{program} is not installed. Please install Node.js first." + )) + } else { + AppCommandError::external_command( + "Failed to execute project creation command", + e.to_string(), + ) + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + return Err(AppCommandError::external_command( + "Project creation command failed", + detail, + )); + } + + Ok(full_path_str) +} diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 79a98be..394aab6 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -604,3 +604,30 @@ pub async fn open_push_window( Ok(()) } + +#[tauri::command] +pub async fn open_project_boot_window(app: AppHandle) -> Result<(), AppCommandError> { + if let Some(existing) = app.get_webview_window("project-boot") { + ensure_windows_undecorated(&existing); + let _ = existing.unminimize(); + existing.set_focus().map_err(|e| { + AppCommandError::window("Failed to focus project boot window", e.to_string()) + })?; + return Ok(()); + } + + let url = WebviewUrl::App("project-boot".into()); + let builder = WebviewWindowBuilder::new(&app, "project-boot", url) + .title("Project Boot") + .inner_size(1400.0, 900.0) + .min_inner_size(1100.0, 700.0) + .center(); + let window = apply_platform_window_style(builder) + .build() + .map_err(|e| { + AppCommandError::window("Failed to open project boot window", e.to_string()) + })?; + ensure_windows_undecorated(&window); + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9badf38..8cc204c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use acp::manager::ConnectionManager; use commands::{ acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands, - notification, system_settings, terminal as terminal_commands, version_control, windows, + notification, project_boot, system_settings, terminal as terminal_commands, version_control, + windows, }; use tauri::Manager; use terminal::manager::TerminalManager; @@ -268,6 +269,8 @@ pub fn run() { windows::open_merge_window, windows::open_stash_window, windows::open_push_window, + windows::open_project_boot_window, + project_boot::create_shadcn_project, system_settings::get_system_proxy_settings, system_settings::update_system_proxy_settings, system_settings::get_system_language_settings, diff --git a/src/app/project-boot/page.tsx b/src/app/project-boot/page.tsx new file mode 100644 index 0000000..1777ffa --- /dev/null +++ b/src/app/project-boot/page.tsx @@ -0,0 +1,41 @@ +"use client" + +import { Suspense, useEffect } from "react" +import { useTranslations } from "next-intl" +import { AppTitleBar } from "@/components/layout/app-title-bar" +import { AppToaster } from "@/components/ui/app-toaster" +import { ProjectBootWorkspace } from "@/components/project-boot/project-boot-workspace" + +function ProjectBootPageInner() { + const t = useTranslations("ProjectBoot") + + useEffect(() => { + document.title = `${t("title")} - codeg` + }, [t]) + + return ( +
+ + {t("title")} +
+ } + /> + +
+ +
+ + + + ) +} + +export default function ProjectBootPage() { + return ( + + + + ) +} diff --git a/src/components/project-boot/project-boot-workspace.tsx b/src/components/project-boot/project-boot-workspace.tsx new file mode 100644 index 0000000..199eed4 --- /dev/null +++ b/src/components/project-boot/project-boot-workspace.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useTranslations } from "next-intl" +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@/components/ui/tabs" +import { ShadcnLauncher } from "./shadcn/shadcn-launcher" + +export function ProjectBootWorkspace() { + const t = useTranslations("ProjectBoot") + + return ( + +
+ + {t("tabs.shadcn")} + +
+ + + + +
+ ) +} diff --git a/src/components/project-boot/shadcn/constants.ts b/src/components/project-boot/shadcn/constants.ts new file mode 100644 index 0000000..06666a7 --- /dev/null +++ b/src/components/project-boot/shadcn/constants.ts @@ -0,0 +1,286 @@ +// ── Preset encoding/decoding (matches shadcn v2 format) ───────────── + +const PRESET_STYLES = [ + "nova", + "vega", + "maia", + "lyra", + "mira", +] as const + +const PRESET_BASE_COLORS = [ + "neutral", + "stone", + "zinc", + "gray", + "mauve", + "olive", + "mist", + "taupe", +] as const + +const PRESET_THEMES = [ + "neutral", + "stone", + "zinc", + "gray", + "amber", + "blue", + "cyan", + "emerald", + "fuchsia", + "green", + "indigo", + "lime", + "orange", + "pink", + "purple", + "red", + "rose", + "sky", + "teal", + "violet", + "yellow", + "mauve", + "olive", + "mist", + "taupe", +] as const + +const PRESET_ICON_LIBRARIES = [ + "lucide", + "hugeicons", + "tabler", + "phosphor", + "remixicon", +] as const + +const PRESET_FONTS = [ + "inter", + "noto-sans", + "nunito-sans", + "figtree", + "roboto", + "raleway", + "dm-sans", + "public-sans", + "outfit", + "jetbrains-mono", + "geist", + "geist-mono", + "lora", + "merriweather", + "playfair-display", + "noto-serif", + "roboto-slab", + "oxanium", + "manrope", + "space-grotesk", + "montserrat", + "ibm-plex-sans", + "source-sans-3", + "instrument-sans", +] as const + +const PRESET_FONT_HEADINGS = ["inherit", ...PRESET_FONTS] as const + +const PRESET_RADII = [ + "default", + "none", + "small", + "medium", + "large", +] as const + +const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const + +const PRESET_MENU_COLORS = [ + "default", + "inverted", + "default-translucent", + "inverted-translucent", +] as const + +/** V2 field layout for bit-packing (order must match shadcn exactly) */ +const PRESET_FIELDS_V2 = [ + { key: "menuColor", values: PRESET_MENU_COLORS, bits: 3 }, + { key: "menuAccent", values: PRESET_MENU_ACCENTS, bits: 3 }, + { key: "radius", values: PRESET_RADII, bits: 4 }, + { key: "font", values: PRESET_FONTS, bits: 6 }, + { key: "iconLibrary", values: PRESET_ICON_LIBRARIES, bits: 6 }, + { key: "theme", values: PRESET_THEMES, bits: 6 }, + { key: "baseColor", values: PRESET_BASE_COLORS, bits: 6 }, + { key: "style", values: PRESET_STYLES, bits: 6 }, + { key: "chartColor", values: PRESET_THEMES, bits: 6 }, + { key: "fontHeading", values: PRESET_FONT_HEADINGS, bits: 5 }, +] as const + +const BASE62 = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +function toBase62(num: number): string { + if (num === 0) return "0" + let result = "" + let n = num + while (n > 0) { + result = BASE62[n % 62] + result + n = Math.floor(n / 62) + } + return result +} + +/** Encode a preset config into a compact base62 code (v2 format). */ +export function encodePreset(config: PresetCodeConfig): string { + const defaults: Record = { + menuColor: "default", + menuAccent: "subtle", + radius: "default", + font: "inter", + iconLibrary: "lucide", + theme: "neutral", + baseColor: "neutral", + style: "nova", + chartColor: config.theme ?? "neutral", + fontHeading: "inherit", + } + const merged: Record = { ...defaults } + for (const [k, v] of Object.entries(config)) { + if (v) merged[k] = v + } + let bits = 0 + let offset = 0 + for (const field of PRESET_FIELDS_V2) { + const idx = (field.values as readonly string[]).indexOf( + merged[field.key] ?? "" + ) + bits += (idx === -1 ? 0 : idx) * 2 ** offset + offset += field.bits + } + return "b" + toBase62(bits) +} + +// ── Config types ──────────────────────────────────────────────────── + +/** Fields that are encoded into the preset code (sent to CLI & preview). */ +export interface PresetCodeConfig { + style: string + baseColor: string + theme: string + chartColor: string + iconLibrary: string + font: string + fontHeading: string + radius: string + menuAccent: string + menuColor: string +} + +/** Full UI config (preset fields + non-preset fields like base/template). */ +export interface ShadcnPresetConfig extends PresetCodeConfig { + base: string + template: string +} + +export const DEFAULT_PRESET_CONFIG: ShadcnPresetConfig = { + base: "radix", + style: "nova", + baseColor: "neutral", + theme: "neutral", + chartColor: "neutral", + iconLibrary: "lucide", + font: "inter", + fontHeading: "inherit", + radius: "default", + menuAccent: "subtle", + menuColor: "default", + template: "start", +} + +// ── UI option arrays ──────────────────────────────────────────────── + +export const BASE_OPTIONS = [ + { value: "radix", label: "Radix" }, + { value: "base", label: "Base" }, +] + +export const STYLE_OPTIONS = PRESET_STYLES.map((v) => ({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export const BASE_COLOR_OPTIONS = PRESET_BASE_COLORS.map((v) => ({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export const THEME_OPTIONS = PRESET_THEMES.map((v) => ({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export const ICON_LIBRARY_OPTIONS = PRESET_ICON_LIBRARIES.map((v) => ({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export const FONT_OPTIONS = PRESET_FONTS.map((v) => ({ + value: v, + label: v + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "), +})) + +export const FONT_HEADING_OPTIONS = PRESET_FONT_HEADINGS.map((v) => ({ + value: v, + label: + v === "inherit" + ? "Inherit" + : v + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "), +})) + +export const MENU_ACCENT_OPTIONS = PRESET_MENU_ACCENTS.map((v) => ({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export const MENU_COLOR_OPTIONS = PRESET_MENU_COLORS.map((v) => ({ + value: v, + label: v + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "), +})) + +export const RADIUS_OPTIONS = PRESET_RADII.map((v) => ({ + value: v, + label: v.charAt(0).toUpperCase() + v.slice(1), +})) + +export const TEMPLATE_OPTIONS = [{ value: "start", label: "Start" }] + +export const FRAMEWORK_OPTIONS = [ + { value: "next", label: "Next.js" }, + { value: "vite", label: "Vite" }, + { value: "start", label: "TanStack Start" }, + { value: "react-router", label: "React Router" }, + { value: "laravel", label: "Laravel" }, + { value: "astro", label: "Astro" }, +] + +export const PACKAGE_MANAGER_OPTIONS = [ + { value: "pnpm", label: "pnpm" }, + { value: "npm", label: "npm" }, + { value: "yarn", label: "yarn" }, + { value: "bun", label: "bun" }, +] + +// ── URL builders ──────────────────────────────────────────────────── + +/** Build the preview iframe URL using a preset code. */ +export function buildPreviewUrl(base: string, presetCode: string): string { + return `https://ui.shadcn.com/preview/${base}/preview?preset=${presetCode}` +} diff --git a/src/components/project-boot/shadcn/create-project-dialog.tsx b/src/components/project-boot/shadcn/create-project-dialog.tsx new file mode 100644 index 0000000..ff94d64 --- /dev/null +++ b/src/components/project-boot/shadcn/create-project-dialog.tsx @@ -0,0 +1,201 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { Loader2, FolderOpen } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { openFileDialog } from "@/lib/platform" +import { createShadcnProject, openFolderWindow } from "@/lib/api" +import { FRAMEWORK_OPTIONS, PACKAGE_MANAGER_OPTIONS } from "./constants" + +interface CreateProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + presetCode: string +} + +export function CreateProjectDialog({ + open, + onOpenChange, + presetCode, +}: CreateProjectDialogProps) { + const t = useTranslations("ProjectBoot") + const [projectName, setProjectName] = useState("my-app") + const [framework, setFramework] = useState("next") + const [packageManager, setPackageManager] = useState("pnpm") + const [saveDirectory, setSaveDirectory] = useState("") + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + const handleBrowse = async () => { + const result = await openFileDialog({ directory: true, multiple: false }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result + setSaveDirectory(selected) + } + + const handleCreate = async () => { + setError(null) + setCreating(true) + try { + const projectPath = await createShadcnProject({ + projectName, + template: framework, + presetCode, + packageManager, + targetDir: saveDirectory, + }) + toast.success(t("toasts.createSuccess")) + onOpenChange(false) + resetForm() + await openFolderWindow(projectPath) + } catch (err) { + const message = + err && typeof err === "object" && "message" in err + ? (err as { message: string }).message + : String(err) + setError(message) + toast.error(t("toasts.createFailed"), { description: message }) + } finally { + setCreating(false) + } + } + + const resetForm = () => { + setProjectName("my-app") + setFramework("next") + setPackageManager("pnpm") + setSaveDirectory("") + setError(null) + } + + const canCreate = + projectName.trim().length > 0 && saveDirectory.trim().length > 0 + + return ( + { + onOpenChange(v) + if (!v) resetForm() + }} + > + + + {t("createDialog.title")} + + +
+
+ + setProjectName(e.target.value)} + placeholder={t("createDialog.projectNamePlaceholder")} + disabled={creating} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ setSaveDirectory(e.target.value)} + placeholder={t("createDialog.saveDirectoryPlaceholder")} + disabled={creating} + className="flex-1" + /> + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/project-boot/shadcn/shadcn-config-panel.tsx b/src/components/project-boot/shadcn/shadcn-config-panel.tsx new file mode 100644 index 0000000..619a90f --- /dev/null +++ b/src/components/project-boot/shadcn/shadcn-config-panel.tsx @@ -0,0 +1,128 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + BASE_OPTIONS, + STYLE_OPTIONS, + BASE_COLOR_OPTIONS, + THEME_OPTIONS, + ICON_LIBRARY_OPTIONS, + FONT_OPTIONS, + FONT_HEADING_OPTIONS, + MENU_ACCENT_OPTIONS, + MENU_COLOR_OPTIONS, + RADIUS_OPTIONS, + TEMPLATE_OPTIONS, + type ShadcnPresetConfig, +} from "./constants" +import { CreateProjectDialog } from "./create-project-dialog" + +interface ShadcnConfigPanelProps { + config: ShadcnPresetConfig + onConfigChange: (key: keyof ShadcnPresetConfig, value: string) => void + presetCode: string +} + +type ConfigI18nKey = + | "config.base" + | "config.style" + | "config.baseColor" + | "config.theme" + | "config.chartColor" + | "config.iconLibrary" + | "config.font" + | "config.fontHeading" + | "config.menuAccent" + | "config.menuColor" + | "config.radius" + | "config.template" + +const CONFIG_FIELDS: { + key: keyof ShadcnPresetConfig + i18nKey: ConfigI18nKey + options: { value: string; label: string }[] +}[] = [ + { key: "base", i18nKey: "config.base", options: BASE_OPTIONS }, + { key: "style", i18nKey: "config.style", options: STYLE_OPTIONS }, + { key: "baseColor", i18nKey: "config.baseColor", options: BASE_COLOR_OPTIONS }, + { key: "theme", i18nKey: "config.theme", options: THEME_OPTIONS }, + { key: "chartColor", i18nKey: "config.chartColor", options: THEME_OPTIONS }, + { + key: "iconLibrary", + i18nKey: "config.iconLibrary", + options: ICON_LIBRARY_OPTIONS, + }, + { key: "font", i18nKey: "config.font", options: FONT_OPTIONS }, + { + key: "fontHeading", + i18nKey: "config.fontHeading", + options: FONT_HEADING_OPTIONS, + }, + { key: "menuAccent", i18nKey: "config.menuAccent", options: MENU_ACCENT_OPTIONS }, + { key: "menuColor", i18nKey: "config.menuColor", options: MENU_COLOR_OPTIONS }, + { key: "radius", i18nKey: "config.radius", options: RADIUS_OPTIONS }, + { key: "template", i18nKey: "config.template", options: TEMPLATE_OPTIONS }, +] + +export function ShadcnConfigPanel({ + config, + onConfigChange, + presetCode, +}: ShadcnConfigPanelProps) { + const t = useTranslations("ProjectBoot") + const [createOpen, setCreateOpen] = useState(false) + + return ( +
+ +
+ {CONFIG_FIELDS.map((field) => ( +
+ + +
+ ))} +
+
+ +
+ +
+ + +
+ ) +} diff --git a/src/components/project-boot/shadcn/shadcn-launcher.tsx b/src/components/project-boot/shadcn/shadcn-launcher.tsx new file mode 100644 index 0000000..38a4c34 --- /dev/null +++ b/src/components/project-boot/shadcn/shadcn-launcher.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useMemo, useState } from "react" +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" +import { ShadcnConfigPanel } from "./shadcn-config-panel" +import { ShadcnPreview } from "./shadcn-preview" +import { + DEFAULT_PRESET_CONFIG, + encodePreset, + buildPreviewUrl, + type ShadcnPresetConfig, +} from "./constants" + +export function ShadcnLauncher() { + const [config, setConfig] = useState( + DEFAULT_PRESET_CONFIG + ) + + const presetCode = useMemo(() => encodePreset(config), [config]) + const previewUrl = useMemo( + () => buildPreviewUrl(config.base, presetCode), + [config.base, presetCode] + ) + + const updateConfig = (key: keyof ShadcnPresetConfig, value: string) => { + setConfig((prev) => ({ ...prev, [key]: value })) + } + + return ( + + + + + + + + + + + + ) +} diff --git a/src/components/project-boot/shadcn/shadcn-preview.tsx b/src/components/project-boot/shadcn/shadcn-preview.tsx new file mode 100644 index 0000000..b333e2c --- /dev/null +++ b/src/components/project-boot/shadcn/shadcn-preview.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { Loader2 } from "lucide-react" + +interface ShadcnPreviewProps { + previewUrl: string +} + +export function ShadcnPreview({ previewUrl }: ShadcnPreviewProps) { + const t = useTranslations("ProjectBoot") + const [debouncedUrl, setDebouncedUrl] = useState(previewUrl) + const [loading, setLoading] = useState(true) + const timerRef = useRef>(null) + + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + timerRef.current = setTimeout(() => { + setDebouncedUrl(previewUrl) + setLoading(true) + }, 500) + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, [previewUrl]) + + return ( +
+ {loading && ( +
+ + + {t("preview.loading")} + +
+ )} +