From 26d79347cde4acbacd47351813aedb39b78356c5 Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Tue, 21 Jul 2026 17:14:37 -0700 Subject: [PATCH] Surface project worktree folder settings, closes #340 --- .../src/components/ProjectSessionList.tsx | 41 ++++++++++++- tests/electronApiMock.ts | 20 +++++++ tests/smoke.spec.ts | 58 ++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index de3cb2d8..f5ac19da 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useMemo, useCallback, useRef, useId } from 'react'; -import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare } from 'lucide-react'; +import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare, Settings } from 'lucide-react'; import { SessionDetailTooltip } from './SessionDetailTooltip'; import { useSessionStore } from '../stores/sessionStore'; import { useNavigationStore } from '../stores/navigationStore'; import { SETTINGS_PREFERENCE_KEYS, normalizeSidebarPaneRowLayout, type SidebarPaneRowLayout } from '../types/settings'; import { CreateSessionDialog } from './CreateSessionDialog'; import { AddProjectDialog } from './AddProjectDialog'; +import ProjectSettings from './ProjectSettings'; import { Dropdown } from './ui/Dropdown'; import { Tooltip } from './ui/Tooltip'; import type { DropdownItem } from './ui/Dropdown'; @@ -52,6 +53,8 @@ export function ProjectSessionList({ const [projects, setProjects] = useState([]); const [showCreateDialog, setShowCreateDialog] = useState(false); const [createForProject, setCreateForProject] = useState(null); + const [settingsProject, setSettingsProject] = useState(null); + const [showProjectSettings, setShowProjectSettings] = useState(false); const [sidebarPaneRowLayout, setSidebarPaneRowLayout] = useState('single'); const knownSessionIdsRef = useRef | null>(null); @@ -195,6 +198,23 @@ export function ProjectSessionList({ setShowCreateDialog(true); }; + const handleOpenProjectSettings = (project: Project) => { + setSettingsProject(project); + setShowProjectSettings(true); + }; + + const handleProjectUpdated = () => { + loadProjects(); + window.dispatchEvent(new Event('project-changed')); + }; + + const handleProjectSettingsDeleted = () => { + setShowProjectSettings(false); + setSettingsProject(null); + loadProjects(); + window.dispatchEvent(new Event('project-changed')); + }; + // Session operations const handleArchiveSession = async (sessionId: string) => { try { @@ -416,6 +436,12 @@ export function ProjectSessionList({ icon: GitBranch, onClick: () => navigateToProject(project.id), }, + { + id: 'project-settings', + label: 'Project Settings', + icon: Settings, + onClick: () => handleOpenProjectSettings(project), + }, { id: 'delete', label: 'Delete Project', @@ -539,6 +565,19 @@ export function ProjectSessionList({ isOpen={showAddProjectDialog} onClose={() => setShowAddProjectDialog(false)} /> + + {settingsProject && ( + { + setShowProjectSettings(false); + setSettingsProject(null); + }} + onUpdate={handleProjectUpdated} + onDelete={handleProjectSettingsDeleted} + /> + )} ); } diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index 9b04e31f..6deec1cd 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -165,6 +165,7 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc let mockActiveProjectId = mockOptions.activeProjectId === undefined ? (mockProjects.find((project) => project.active === true)?.id as number | undefined) ?? null : mockOptions.activeProjectId; + let lastProjectUpdate: { projectId: string; updates: Record } | null = null; let cloudDisconnectError: string | null = null; let configGetCount = 0; let nextConfigUpdateError: string | null = null; @@ -372,6 +373,12 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc folders: namespace({ getByProject: () => success([]), }), + git: namespace({ + detectBranch: () => success('main'), + }), + dialog: namespace({ + openDirectory: () => success('/tmp/pane-worktrees'), + }), onboarding: namespace({ detectEnvironment: () => success({}), getGitHubAuthCommand: () => success({ command: '', reason: 'ready' }), @@ -431,6 +438,16 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc { name: 'origin/main', isCurrent: false, hasWorktree: false, isRemote: true }, { name: 'main', isCurrent: true, hasWorktree: false, isRemote: false }, ]), + update: (projectId: string, updates: Record) => { + lastProjectUpdate = { projectId, updates: clone(updates) }; + mockProjects = mockProjects.map((project) => ( + String(project.id) === projectId + ? { ...project, ...clone(updates), updated_at: new Date().toISOString() } + : project + )); + return success(mockProjects.find((project) => String(project.id) === projectId) ?? null); + }, + detectConfig: () => success(null), refreshGitStatus: () => success(), }), prompts: namespace({ @@ -742,6 +759,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc getSessionFavoriteToggleCalls() { return clone(sessionFavoriteToggleCalls); }, + getProjectUpdates() { + return lastProjectUpdate ? [clone(lastProjectUpdate)] : []; + }, }, }); }, options); diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index fe3bdccf..b59ed2b9 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -2,7 +2,25 @@ import { test, expect, Page } from '@playwright/test'; import { installElectronApiMock } from './electronApiMock'; test.beforeEach(async ({ page }) => { - await installElectronApiMock(page); + await installElectronApiMock(page, { + initialProjects: [ + { + id: 1, + name: 'Mock Repo', + path: '/tmp/mock-repo', + system_prompt: null, + run_script: null, + build_script: null, + archive_script: null, + active: true, + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), + open_ide_command: null, + displayOrder: 0, + worktree_folder: null, + }, + ], + }); }); async function dismissStartupDialogs(page: Page) { @@ -128,6 +146,44 @@ test.describe('Smoke Tests', () => { await expect(page.getByText('Something went wrong')).toHaveCount(0); }); + test('Repository menu opens Project Settings with editable Worktree Folder', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + + await dismissStartupDialogs(page); + + const repoActionsButton = page.getByRole('button', { name: 'Repository actions for Mock Repo' }); + await expect(repoActionsButton).toBeVisible({ timeout: 5000 }); + await clickDomNode(repoActionsButton); + + await clickDomNode(page.getByRole('menuitem', { name: 'Project Settings' })); + + await expect(page.getByText('Project Settings')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Worktree Folder')).toBeVisible(); + + const worktreeFolderInput = page.getByPlaceholder('worktrees'); + await setInputValue(worktreeFolderInput, '/tmp/pane-worktrees'); + await clickDomNode(page.getByRole('button', { name: 'Save Changes' }).first()); + + const projectUpdates = await page.evaluate(() => { + const mock = (window as typeof window & { + __paneTestElectronMock?: { + getProjectUpdates: () => Array<{ projectId: string; updates: Record }>; + }; + }).__paneTestElectronMock; + + return mock?.getProjectUpdates() ?? []; + }); + + expect(projectUpdates).toHaveLength(1); + expect(projectUpdates[0]).toMatchObject({ + projectId: '1', + updates: { + worktree_folder: '/tmp/pane-worktrees', + }, + }); + await expect(page.getByText('Something went wrong')).toHaveCount(0); + }); + test('Remote daemon settings can create a paired profile and switch modes', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });