From b32af0c86bbd71136d0de30841b2051f6f100986 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 23:06:22 -0500 Subject: [PATCH] feat: implement upsert project functionality in sidebar and welcome view - Refactored project handling in Sidebar and WelcomeView components to use a new `upsertAndSetCurrentProject` action for creating or updating projects. - Enhanced theme preservation logic during project creation and updates by integrating theme management directly into the store action. - Cleaned up redundant code related to project existence checks and state updates, improving maintainability and readability. --- apps/app/src/components/layout/sidebar.tsx | 49 ++++----------- .../app/src/components/views/welcome-view.tsx | 57 ++++++++--------- apps/app/src/store/app-store.ts | 61 ++++++++++++++++++- apps/server/src/routes/templates.ts | 10 +++ 4 files changed, 105 insertions(+), 72 deletions(-) diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 8476483c..90b3d739 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { useAppStore, formatShortcut } from "@/store/app-store"; +import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store"; import { CoursePromoBadge } from "@/components/ui/course-promo-badge"; import { IS_MARKETING } from "@/config/app-config"; import { @@ -188,7 +188,7 @@ export function Sidebar() { currentView, sidebarOpen, projectHistory, - addProject, + upsertAndSetCurrentProject, setCurrentProject, setCurrentView, toggleSidebar, @@ -473,39 +473,14 @@ export function Sidebar() { return; } - // Check if project already exists (by path) to preserve theme and other settings - const existingProject = projects.find((p) => p.path === path); - - let project: Project; - if (existingProject) { - // Update existing project, preserving theme and other properties - project = { - ...existingProject, - name, // Update name in case it changed - lastOpened: new Date().toISOString(), - }; - // Update the project in the store (this will update the existing entry) - const updatedProjects = projects.map((p) => - p.id === existingProject.id ? project : p - ); - useAppStore.setState({ projects: updatedProjects }); - } else { - // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) - // Then fall back to current effective theme, then global theme - const trashedProject = trashedProjects.find((p) => p.path === path); - const effectiveTheme = - trashedProject?.theme || currentProject?.theme || globalTheme; - project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - theme: effectiveTheme, - }; - addProject(project); - } - - setCurrentProject(project); + // Upsert project and set as current (handles both create and update cases) + // Theme preservation is handled by the store action + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + const project = upsertAndSetCurrentProject(path, name, effectiveTheme); // Check if app_spec.txt exists const specExists = await hasAppSpec(path); @@ -540,10 +515,8 @@ export function Sidebar() { } } }, [ - projects, trashedProjects, - addProject, - setCurrentProject, + upsertAndSetCurrentProject, currentProject, globalTheme, ]); diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 36744cb1..033d3bef 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, type ThemeMode } from "@/store/app-store"; import { getElectronAPI, type Project } from "@/lib/electron"; import { initializeProject } from "@/lib/project-init"; import { @@ -36,8 +36,14 @@ import { getHttpApiClient } from "@/lib/http-api-client"; import type { StarterTemplate } from "@/lib/templates"; export function WelcomeView() { - const { projects, addProject, setCurrentProject, setCurrentView } = - useAppStore(); + const { + projects, + trashedProjects, + currentProject, + upsertAndSetCurrentProject, + setCurrentView, + theme: globalTheme, + } = useAppStore(); const [showNewProjectModal, setShowNewProjectModal] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isOpening, setIsOpening] = useState(false); @@ -98,35 +104,14 @@ export function WelcomeView() { return; } - // Check if project already exists (by path) to preserve theme and other settings - const existingProject = projects.find((p) => p.path === path); - - let project: Project; - if (existingProject) { - // Update existing project, preserving theme and other properties - project = { - ...existingProject, - name, // Update name in case it changed - lastOpened: new Date().toISOString(), - }; - // Update the project in the store (this will update the existing entry) - const updatedProjects = projects.map((p) => - p.id === existingProject.id ? project : p - ); - // We need to manually update projects since addProject would create a duplicate - useAppStore.setState({ projects: updatedProjects }); - } else { - // Create new project - project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - }; - addProject(project); - } - - setCurrentProject(project); + // Upsert project and set as current (handles both create and update cases) + // Theme preservation is handled by the store action + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + const project = upsertAndSetCurrentProject(path, name, effectiveTheme); // Show initialization dialog if files were created if (initResult.createdFiles && initResult.createdFiles.length > 0) { @@ -161,7 +146,13 @@ export function WelcomeView() { setIsOpening(false); } }, - [projects, addProject, setCurrentProject, analyzeProject] + [ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + analyzeProject, + ] ); const handleOpenProject = useCallback(async () => { diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 53ba746f..c7d24da1 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -416,6 +416,11 @@ export interface AppActions { deleteTrashedProject: (projectId: string) => void; emptyTrash: () => void; setCurrentProject: (project: Project | null) => void; + upsertAndSetCurrentProject: ( + path: string, + name: string, + theme?: ThemeMode + ) => Project; // Upsert project by path and set as current reorderProjects: (oldIndex: number, newIndex: number) => void; cyclePrevProject: () => void; // Cycle back through project history (Q) cycleNextProject: () => void; // Cycle forward through project history (E) @@ -767,6 +772,58 @@ export const useAppStore = create()( } }, + upsertAndSetCurrentProject: (path, name, theme) => { + const { + projects, + trashedProjects, + currentProject, + theme: globalTheme, + } = get(); + const existingProject = projects.find((p) => p.path === path); + let project: Project; + + if (existingProject) { + // Update existing project, preserving theme and other properties + project = { + ...existingProject, + name, // Update name in case it changed + lastOpened: new Date().toISOString(), + }; + // Update the project in the store + const updatedProjects = projects.map((p) => + p.id === existingProject.id ? project : p + ); + set({ projects: updatedProjects }); + } else { + // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) + // Then fall back to provided theme, then current project theme, then global theme + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + theme || + trashedProject?.theme || + currentProject?.theme || + globalTheme; + project = { + id: `project-${Date.now()}`, + name, + path, + lastOpened: new Date().toISOString(), + theme: effectiveTheme, + }; + // Add the new project to the store + set({ + projects: [ + ...projects, + { ...project, lastOpened: new Date().toISOString() }, + ], + }); + } + + // Set as current project (this will also update history and view) + get().setCurrentProject(project); + return project; + }, + cyclePrevProject: () => { const { projectHistory, projectHistoryIndex, projects } = get(); @@ -1222,7 +1279,9 @@ export const useAppStore = create()( const current = get().lastSelectedSessionByProject; if (sessionId === null) { // Remove the entry for this project - const { [projectPath]: _, ...rest } = current; + const rest = Object.fromEntries( + Object.entries(current).filter(([key]) => key !== projectPath) + ); set({ lastSelectedSessionByProject: rest }); } else { set({ diff --git a/apps/server/src/routes/templates.ts b/apps/server/src/routes/templates.ts index 3dd27bd2..a4f5504c 100644 --- a/apps/server/src/routes/templates.ts +++ b/apps/server/src/routes/templates.ts @@ -55,6 +55,16 @@ export function createTemplatesRoutes(): Router { // Build full project path const projectPath = path.join(parentDir, sanitizedName); + const resolvedParent = path.resolve(parentDir); + const resolvedProject = path.resolve(projectPath); + const relativePath = path.relative(resolvedParent, resolvedProject); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return res.status(400).json({ + success: false, + error: "Invalid project name; potential path traversal attempt.", + }); + } + // Check if directory already exists try { await fs.access(projectPath);