diff --git a/apps/server/src/lib/app-spec-format.ts b/apps/server/src/lib/app-spec-format.ts index a52bf1f7..f8393cf1 100644 --- a/apps/server/src/lib/app-spec-format.ts +++ b/apps/server/src/lib/app-spec-format.ts @@ -13,7 +13,7 @@ export { specOutputSchema } from '@automaker/types'; * Escape special XML characters * Handles undefined/null values by converting them to empty strings */ -function escapeXml(str: string | undefined | null): string { +export function escapeXml(str: string | undefined | null): string { if (str == null) { return ''; } diff --git a/apps/server/src/lib/xml-extractor.ts b/apps/server/src/lib/xml-extractor.ts new file mode 100644 index 00000000..49dbc368 --- /dev/null +++ b/apps/server/src/lib/xml-extractor.ts @@ -0,0 +1,465 @@ +/** + * XML Extraction Utilities + * + * Robust XML parsing utilities for extracting and updating sections + * from app_spec.txt XML content. Uses regex-based parsing which is + * sufficient for our controlled XML structure. + * + * Note: If more complex XML parsing is needed in the future, consider + * using a library like 'fast-xml-parser' or 'xml2js'. + */ + +import { createLogger } from '@automaker/utils'; +import type { SpecOutput } from '@automaker/types'; + +const logger = createLogger('XmlExtractor'); + +/** + * Represents an implemented feature extracted from XML + */ +export interface ImplementedFeature { + name: string; + description: string; + file_locations?: string[]; +} + +/** + * Logger interface for optional custom logging + */ +export interface XmlExtractorLogger { + debug: (message: string, ...args: unknown[]) => void; + warn?: (message: string, ...args: unknown[]) => void; +} + +/** + * Options for XML extraction operations + */ +export interface ExtractXmlOptions { + /** Custom logger (defaults to internal logger) */ + logger?: XmlExtractorLogger; +} + +/** + * Escape special XML characters + * Handles undefined/null values by converting them to empty strings + */ +export function escapeXml(str: string | undefined | null): string { + if (str == null) { + return ''; + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Unescape XML entities back to regular characters + */ +export function unescapeXml(str: string): string { + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +/** + * Extract the content of a specific XML section + * + * @param xmlContent - The full XML content + * @param tagName - The tag name to extract (e.g., 'implemented_features') + * @param options - Optional extraction options + * @returns The content between the tags, or null if not found + */ +export function extractXmlSection( + xmlContent: string, + tagName: string, + options: ExtractXmlOptions = {} +): string | null { + const log = options.logger || logger; + + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i'); + const match = xmlContent.match(regex); + + if (match) { + log.debug(`Extracted <${tagName}> section`); + return match[1]; + } + + log.debug(`Section <${tagName}> not found`); + return null; +} + +/** + * Extract all values from repeated XML elements + * + * @param xmlContent - The XML content to search + * @param tagName - The tag name to extract values from + * @param options - Optional extraction options + * @returns Array of extracted values (unescaped) + */ +export function extractXmlElements( + xmlContent: string, + tagName: string, + options: ExtractXmlOptions = {} +): string[] { + const log = options.logger || logger; + const values: string[] = []; + + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g'); + const matches = xmlContent.matchAll(regex); + + for (const match of matches) { + values.push(unescapeXml(match[1].trim())); + } + + log.debug(`Extracted ${values.length} <${tagName}> elements`); + return values; +} + +/** + * Extract implemented features from app_spec.txt XML content + * + * @param specContent - The full XML content of app_spec.txt + * @param options - Optional extraction options + * @returns Array of implemented features with name, description, and optional file_locations + */ +export function extractImplementedFeatures( + specContent: string, + options: ExtractXmlOptions = {} +): ImplementedFeature[] { + const log = options.logger || logger; + const features: ImplementedFeature[] = []; + + // Match ... section + const implementedSection = extractXmlSection(specContent, 'implemented_features', options); + + if (!implementedSection) { + log.debug('No implemented_features section found'); + return features; + } + + // Extract individual feature blocks + const featureRegex = /([\s\S]*?)<\/feature>/g; + const featureMatches = implementedSection.matchAll(featureRegex); + + for (const featureMatch of featureMatches) { + const featureContent = featureMatch[1]; + + // Extract name + const nameMatch = featureContent.match(/([\s\S]*?)<\/name>/); + const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; + + // Extract description + const descMatch = featureContent.match(/([\s\S]*?)<\/description>/); + const description = descMatch ? unescapeXml(descMatch[1].trim()) : ''; + + // Extract file_locations if present + const locationsSection = extractXmlSection(featureContent, 'file_locations', options); + const file_locations = locationsSection + ? extractXmlElements(locationsSection, 'location', options) + : undefined; + + if (name) { + features.push({ + name, + description, + ...(file_locations && file_locations.length > 0 ? { file_locations } : {}), + }); + } + } + + log.debug(`Extracted ${features.length} implemented features`); + return features; +} + +/** + * Extract only the feature names from implemented_features section + * + * @param specContent - The full XML content of app_spec.txt + * @param options - Optional extraction options + * @returns Array of feature names + */ +export function extractImplementedFeatureNames( + specContent: string, + options: ExtractXmlOptions = {} +): string[] { + const features = extractImplementedFeatures(specContent, options); + return features.map((f) => f.name); +} + +/** + * Generate XML for a single implemented feature + * + * @param feature - The feature to convert to XML + * @param indent - The base indentation level (default: 2 spaces) + * @returns XML string for the feature + */ +export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string { + const i2 = indent.repeat(2); + const i3 = indent.repeat(3); + const i4 = indent.repeat(4); + + let xml = `${i2} +${i3}${escapeXml(feature.name)} +${i3}${escapeXml(feature.description)}`; + + if (feature.file_locations && feature.file_locations.length > 0) { + xml += ` +${i3} +${feature.file_locations.map((loc) => `${i4}${escapeXml(loc)}`).join('\n')} +${i3}`; + } + + xml += ` +${i2}`; + + return xml; +} + +/** + * Generate XML for an array of implemented features + * + * @param features - Array of features to convert to XML + * @param indent - The base indentation level (default: 2 spaces) + * @returns XML string for the implemented_features section content + */ +export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string { + return features.map((f) => featureToXml(f, indent)).join('\n'); +} + +/** + * Update the implemented_features section in XML content + * + * @param specContent - The full XML content + * @param newFeatures - The new features to set + * @param options - Optional extraction options + * @returns Updated XML content with the new implemented_features section + */ +export function updateImplementedFeaturesSection( + specContent: string, + newFeatures: ImplementedFeature[], + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + const indent = ' '; + + // Generate new section content + const newSectionContent = featuresToXml(newFeatures, indent); + + // Build the new section + const newSection = ` +${newSectionContent} +${indent}`; + + // Check if section exists + const sectionRegex = /[\s\S]*?<\/implemented_features>/; + + if (sectionRegex.test(specContent)) { + log.debug('Replacing existing implemented_features section'); + return specContent.replace(sectionRegex, newSection); + } + + // If section doesn't exist, try to insert after core_capabilities + const coreCapabilitiesEnd = ''; + const insertIndex = specContent.indexOf(coreCapabilitiesEnd); + + if (insertIndex !== -1) { + const insertPosition = insertIndex + coreCapabilitiesEnd.length; + log.debug('Inserting implemented_features after core_capabilities'); + return ( + specContent.slice(0, insertPosition) + + '\n\n' + + indent + + newSection + + specContent.slice(insertPosition) + ); + } + + // As a fallback, insert before + const projectSpecEnd = ''; + const fallbackIndex = specContent.indexOf(projectSpecEnd); + + if (fallbackIndex !== -1) { + log.debug('Inserting implemented_features before '); + return ( + specContent.slice(0, fallbackIndex) + + indent + + newSection + + '\n' + + specContent.slice(fallbackIndex) + ); + } + + log.warn?.('Could not find appropriate insertion point for implemented_features'); + log.debug('Could not find appropriate insertion point for implemented_features'); + return specContent; +} + +/** + * Add a new feature to the implemented_features section + * + * @param specContent - The full XML content + * @param newFeature - The feature to add + * @param options - Optional extraction options + * @returns Updated XML content with the new feature added + */ +export function addImplementedFeature( + specContent: string, + newFeature: ImplementedFeature, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Check for duplicates by name + const isDuplicate = existingFeatures.some( + (f) => f.name.toLowerCase() === newFeature.name.toLowerCase() + ); + + if (isDuplicate) { + log.debug(`Feature "${newFeature.name}" already exists, skipping`); + return specContent; + } + + // Add the new feature + const updatedFeatures = [...existingFeatures, newFeature]; + + log.debug(`Adding feature "${newFeature.name}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Remove a feature from the implemented_features section by name + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to remove + * @param options - Optional extraction options + * @returns Updated XML content with the feature removed + */ +export function removeImplementedFeature( + specContent: string, + featureName: string, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Filter out the feature to remove + const updatedFeatures = existingFeatures.filter( + (f) => f.name.toLowerCase() !== featureName.toLowerCase() + ); + + if (updatedFeatures.length === existingFeatures.length) { + log.debug(`Feature "${featureName}" not found, no changes made`); + return specContent; + } + + log.debug(`Removing feature "${featureName}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Update an existing feature in the implemented_features section + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to update + * @param updates - Partial updates to apply to the feature + * @param options - Optional extraction options + * @returns Updated XML content with the feature modified + */ +export function updateImplementedFeature( + specContent: string, + featureName: string, + updates: Partial, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Find and update the feature + let found = false; + const updatedFeatures = existingFeatures.map((f) => { + if (f.name.toLowerCase() === featureName.toLowerCase()) { + found = true; + return { + ...f, + ...updates, + // Preserve the original name if not explicitly updated + name: updates.name ?? f.name, + }; + } + return f; + }); + + if (!found) { + log.debug(`Feature "${featureName}" not found, no changes made`); + return specContent; + } + + log.debug(`Updating feature "${featureName}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Check if a feature exists in the implemented_features section + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to check + * @param options - Optional extraction options + * @returns True if the feature exists + */ +export function hasImplementedFeature( + specContent: string, + featureName: string, + options: ExtractXmlOptions = {} +): boolean { + const features = extractImplementedFeatures(specContent, options); + return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase()); +} + +/** + * Convert extracted features to SpecOutput.implemented_features format + * + * @param features - Array of extracted features + * @returns Features in SpecOutput format + */ +export function toSpecOutputFeatures( + features: ImplementedFeature[] +): SpecOutput['implemented_features'] { + return features.map((f) => ({ + name: f.name, + description: f.description, + ...(f.file_locations && f.file_locations.length > 0 + ? { file_locations: f.file_locations } + : {}), + })); +} + +/** + * Convert SpecOutput.implemented_features to ImplementedFeature format + * + * @param specFeatures - Features from SpecOutput + * @returns Features in ImplementedFeature format + */ +export function fromSpecOutputFeatures( + specFeatures: SpecOutput['implemented_features'] +): ImplementedFeature[] { + return specFeatures.map((f) => ({ + name: f.name, + description: f.description, + ...(f.file_locations && f.file_locations.length > 0 + ? { file_locations: f.file_locations } + : {}), + })); +} diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index e7a11f83..a4df45a6 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -24,6 +24,19 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event return; } + // Check for duplicate title if title is provided + if (feature.title && feature.title.trim()) { + const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title); + if (duplicate) { + res.status(409).json({ + success: false, + error: `A feature with title "${feature.title}" already exists`, + duplicateFeatureId: duplicate.id, + }); + return; + } + } + const created = await featureLoader.create(projectPath, feature); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 1a89cda3..a5b532c1 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -4,8 +4,14 @@ import type { Request, Response } from 'express'; import { FeatureLoader } from '../../../services/feature-loader.js'; -import type { Feature } from '@automaker/types'; +import type { Feature, FeatureStatus } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('features/update'); + +// Statuses that should trigger syncing to app_spec.txt +const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed']; export function createUpdateHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { @@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { return; } + // Check for duplicate title if title is being updated + if (updates.title && updates.title.trim()) { + const duplicate = await featureLoader.findDuplicateTitle( + projectPath, + updates.title, + featureId // Exclude the current feature from duplicate check + ); + if (duplicate) { + res.status(409).json({ + success: false, + error: `A feature with title "${updates.title}" already exists`, + duplicateFeatureId: duplicate.id, + }); + return; + } + } + + // Get the current feature to detect status changes + const currentFeature = await featureLoader.get(projectPath, featureId); + const previousStatus = currentFeature?.status as FeatureStatus | undefined; + const newStatus = updates.status as FeatureStatus | undefined; + const updated = await featureLoader.update( projectPath, featureId, @@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { enhancementMode, preEnhancementDescription ); + + // Trigger sync to app_spec.txt when status changes to verified or completed + if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) { + try { + const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated); + if (synced) { + logger.info( + `Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}` + ); + } + } catch (syncError) { + // Log the sync error but don't fail the update operation + logger.error(`Failed to sync feature to app_spec.txt:`, syncError); + } + } + res.json({ success: true, feature: updated }); } catch (error) { logError(error, 'Update feature failed'); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index d97e3402..1993836b 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -2125,6 +2125,16 @@ Format your response as a structured markdown document.`; projectPath, }); } + + // Sync completed/verified features to app_spec.txt + if (status === 'verified' || status === 'completed') { + try { + await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); + } catch (syncError) { + // Log but don't fail the status update if sync fails + logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); + } + } } catch { // Feature file may not exist } diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 409abd2a..6ae67c6c 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -11,8 +11,10 @@ import { getFeaturesDir, getFeatureDir, getFeatureImagesDir, + getAppSpecPath, ensureAutomakerDir, } from '@automaker/platform'; +import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js'; const logger = createLogger('FeatureLoader'); @@ -236,6 +238,69 @@ export class FeatureLoader { } } + /** + * Normalize a title for comparison (case-insensitive, trimmed) + */ + private normalizeTitle(title: string): string { + return title.toLowerCase().trim(); + } + + /** + * Find a feature by its title (case-insensitive match) + * @param projectPath - Path to the project + * @param title - Title to search for + * @returns The matching feature or null if not found + */ + async findByTitle(projectPath: string, title: string): Promise { + if (!title || !title.trim()) { + return null; + } + + const normalizedTitle = this.normalizeTitle(title); + const features = await this.getAll(projectPath); + + for (const feature of features) { + if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { + return feature; + } + } + + return null; + } + + /** + * Check if a title already exists on another feature (for duplicate detection) + * @param projectPath - Path to the project + * @param title - Title to check + * @param excludeFeatureId - Optional feature ID to exclude from the check (for updates) + * @returns The duplicate feature if found, null otherwise + */ + async findDuplicateTitle( + projectPath: string, + title: string, + excludeFeatureId?: string + ): Promise { + if (!title || !title.trim()) { + return null; + } + + const normalizedTitle = this.normalizeTitle(title); + const features = await this.getAll(projectPath); + + for (const feature of features) { + // Skip the feature being updated (if provided) + if (excludeFeatureId && feature.id === excludeFeatureId) { + continue; + } + + if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { + return feature; + } + } + + return null; + } + /** * Get a single feature by ID */ @@ -460,4 +525,64 @@ export class FeatureLoader { } } } + + /** + * Sync a completed feature to the app_spec.txt implemented_features section + * + * When a feature is completed, this method adds it to the implemented_features + * section of the project's app_spec.txt file. This keeps the spec in sync + * with the actual state of the codebase. + * + * @param projectPath - Path to the project + * @param feature - The feature to sync (must have title or description) + * @param fileLocations - Optional array of file paths where the feature was implemented + * @returns True if the spec was updated, false if no spec exists or feature was skipped + */ + async syncFeatureToAppSpec( + projectPath: string, + feature: Feature, + fileLocations?: string[] + ): Promise { + try { + const appSpecPath = getAppSpecPath(projectPath); + + // Read the current app_spec.txt + let specContent: string; + try { + specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`); + return false; + } + throw error; + } + + // Build the implemented feature entry + const featureName = feature.title || `Feature: ${feature.id}`; + const implementedFeature: ImplementedFeature = { + name: featureName, + description: feature.description, + ...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}), + }; + + // Add the feature to the implemented_features section + const updatedSpecContent = addImplementedFeature(specContent, implementedFeature); + + // Check if the content actually changed (feature might already exist) + if (updatedSpecContent === specContent) { + logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`); + return false; + } + + // Write the updated spec back to the file + await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8'); + + logger.info(`Synced feature "${featureName}" to app_spec.txt`); + return true; + } catch (error) { + logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error); + throw error; + } + } } diff --git a/apps/server/tests/unit/lib/xml-extractor.test.ts b/apps/server/tests/unit/lib/xml-extractor.test.ts new file mode 100644 index 00000000..750a5f33 --- /dev/null +++ b/apps/server/tests/unit/lib/xml-extractor.test.ts @@ -0,0 +1,1027 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + escapeXml, + unescapeXml, + extractXmlSection, + extractXmlElements, + extractImplementedFeatures, + extractImplementedFeatureNames, + featureToXml, + featuresToXml, + updateImplementedFeaturesSection, + addImplementedFeature, + removeImplementedFeature, + updateImplementedFeature, + hasImplementedFeature, + toSpecOutputFeatures, + fromSpecOutputFeatures, + type ImplementedFeature, + type XmlExtractorLogger, +} from '@/lib/xml-extractor.js'; + +describe('xml-extractor.ts', () => { + // Mock logger for testing custom logger functionality + const createMockLogger = (): XmlExtractorLogger & { calls: string[] } => { + const calls: string[] = []; + return { + calls, + debug: vi.fn((msg: string) => calls.push(`debug: ${msg}`)), + warn: vi.fn((msg: string) => calls.push(`warn: ${msg}`)), + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('escapeXml', () => { + it('should escape ampersand', () => { + expect(escapeXml('foo & bar')).toBe('foo & bar'); + }); + + it('should escape less than', () => { + expect(escapeXml('a < b')).toBe('a < b'); + }); + + it('should escape greater than', () => { + expect(escapeXml('a > b')).toBe('a > b'); + }); + + it('should escape double quotes', () => { + expect(escapeXml('say "hello"')).toBe('say "hello"'); + }); + + it('should escape single quotes', () => { + expect(escapeXml("it's" + ' fine')).toBe('it's fine'); + }); + + it('should handle null', () => { + expect(escapeXml(null)).toBe(''); + }); + + it('should handle undefined', () => { + expect(escapeXml(undefined)).toBe(''); + }); + + it('should handle empty string', () => { + expect(escapeXml('')).toBe(''); + }); + + it('should escape multiple special characters', () => { + expect(escapeXml('a < b & c > d "e" \'f\'')).toBe( + 'a < b & c > d "e" 'f'' + ); + }); + }); + + describe('unescapeXml', () => { + it('should unescape ampersand', () => { + expect(unescapeXml('foo & bar')).toBe('foo & bar'); + }); + + it('should unescape less than', () => { + expect(unescapeXml('a < b')).toBe('a < b'); + }); + + it('should unescape greater than', () => { + expect(unescapeXml('a > b')).toBe('a > b'); + }); + + it('should unescape double quotes', () => { + expect(unescapeXml('say "hello"')).toBe('say "hello"'); + }); + + it('should unescape single quotes', () => { + expect(unescapeXml('it's fine')).toBe("it's fine"); + }); + + it('should handle empty string', () => { + expect(unescapeXml('')).toBe(''); + }); + + it('should roundtrip with escapeXml', () => { + const original = 'Test & "quoted" \'apostrophe\''; + expect(unescapeXml(escapeXml(original))).toBe(original); + }); + }); + + describe('extractXmlSection', () => { + it('should extract section content', () => { + const xml = '
content here
'; + expect(extractXmlSection(xml, 'section')).toBe('content here'); + }); + + it('should extract multiline section content', () => { + const xml = ` +
+ line 1 + line 2 +
+
`; + expect(extractXmlSection(xml, 'section')).toContain('line 1'); + expect(extractXmlSection(xml, 'section')).toContain('line 2'); + }); + + it('should return null for non-existent section', () => { + const xml = 'content'; + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should be case-insensitive', () => { + const xml = '
content
'; + expect(extractXmlSection(xml, 'section')).toBe('content'); + }); + + it('should handle empty section', () => { + const xml = '
'; + expect(extractXmlSection(xml, 'section')).toBe(''); + }); + }); + + describe('extractXmlElements', () => { + it('should extract all element values', () => { + const xml = 'onetwothree'; + expect(extractXmlElements(xml, 'item')).toEqual(['one', 'two', 'three']); + }); + + it('should return empty array for non-existent elements', () => { + const xml = 'value'; + expect(extractXmlElements(xml, 'item')).toEqual([]); + }); + + it('should trim whitespace', () => { + const xml = ' spaced '; + expect(extractXmlElements(xml, 'item')).toEqual(['spaced']); + }); + + it('should unescape XML entities', () => { + const xml = 'foo & bar'; + expect(extractXmlElements(xml, 'item')).toEqual(['foo & bar']); + }); + + it('should handle empty elements', () => { + const xml = 'value'; + expect(extractXmlElements(xml, 'item')).toEqual(['', 'value']); + }); + }); + + describe('extractImplementedFeatures', () => { + const sampleSpec = ` + + Test Project + + + Feature One + First feature description + + + Feature Two + Second feature description + + src/feature-two.ts + src/utils/helper.ts + + + +`; + + it('should extract all features', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features).toHaveLength(2); + }); + + it('should extract feature names', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].name).toBe('Feature One'); + expect(features[1].name).toBe('Feature Two'); + }); + + it('should extract feature descriptions', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].description).toBe('First feature description'); + expect(features[1].description).toBe('Second feature description'); + }); + + it('should extract file_locations when present', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].file_locations).toBeUndefined(); + expect(features[1].file_locations).toEqual(['src/feature-two.ts', 'src/utils/helper.ts']); + }); + + it('should return empty array for missing section', () => { + const xml = + 'Test'; + expect(extractImplementedFeatures(xml)).toEqual([]); + }); + + it('should return empty array for empty section', () => { + const xml = ` + + + `; + expect(extractImplementedFeatures(xml)).toEqual([]); + }); + + it('should handle escaped content', () => { + const xml = ` + + Test & Feature + Uses <brackets> + + `; + const features = extractImplementedFeatures(xml); + expect(features[0].name).toBe('Test & Feature'); + expect(features[0].description).toBe('Uses '); + }); + }); + + describe('extractImplementedFeatureNames', () => { + it('should return only feature names', () => { + const xml = ` + + Feature A + Description A + + + Feature B + Description B + + `; + expect(extractImplementedFeatureNames(xml)).toEqual(['Feature A', 'Feature B']); + }); + + it('should return empty array for no features', () => { + const xml = ''; + expect(extractImplementedFeatureNames(xml)).toEqual([]); + }); + }); + + describe('featureToXml', () => { + it('should generate XML for feature without file_locations', () => { + const feature: ImplementedFeature = { + name: 'My Feature', + description: 'Feature description', + }; + const xml = featureToXml(feature); + expect(xml).toContain('My Feature'); + expect(xml).toContain('Feature description'); + expect(xml).not.toContain(''); + }); + + it('should generate XML for feature with file_locations', () => { + const feature: ImplementedFeature = { + name: 'My Feature', + description: 'Feature description', + file_locations: ['src/index.ts', 'src/utils.ts'], + }; + const xml = featureToXml(feature); + expect(xml).toContain(''); + expect(xml).toContain('src/index.ts'); + expect(xml).toContain('src/utils.ts'); + }); + + it('should escape special characters', () => { + const feature: ImplementedFeature = { + name: 'Test & Feature', + description: 'Has ', + }; + const xml = featureToXml(feature); + expect(xml).toContain('Test & Feature'); + expect(xml).toContain('Has <tags>'); + }); + + it('should not include empty file_locations array', () => { + const feature: ImplementedFeature = { + name: 'Feature', + description: 'Desc', + file_locations: [], + }; + const xml = featureToXml(feature); + expect(xml).not.toContain(''); + }); + }); + + describe('featuresToXml', () => { + it('should generate XML for multiple features', () => { + const features: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2' }, + ]; + const xml = featuresToXml(features); + expect(xml).toContain('Feature 1'); + expect(xml).toContain('Feature 2'); + }); + + it('should handle empty array', () => { + expect(featuresToXml([])).toBe(''); + }); + }); + + describe('updateImplementedFeaturesSection', () => { + const baseSpec = ` + + Test + + Testing + + + + Old Feature + Old description + + +`; + + it('should replace existing section', () => { + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(baseSpec, newFeatures); + expect(result).toContain('New Feature'); + expect(result).not.toContain('Old Feature'); + }); + + it('should insert section after core_capabilities if missing', () => { + const specWithoutSection = ` + + Test + + Testing + +`; + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(specWithoutSection, newFeatures); + expect(result).toContain(''); + expect(result).toContain('New Feature'); + }); + + it('should handle multiple features', () => { + const newFeatures: ImplementedFeature[] = [ + { name: 'Feature A', description: 'Desc A' }, + { name: 'Feature B', description: 'Desc B', file_locations: ['src/b.ts'] }, + ]; + const result = updateImplementedFeaturesSection(baseSpec, newFeatures); + expect(result).toContain('Feature A'); + expect(result).toContain('Feature B'); + expect(result).toContain('src/b.ts'); + }); + }); + + describe('addImplementedFeature', () => { + const baseSpec = ` + + Existing Feature + Existing description + + `; + + it('should add new feature', () => { + const newFeature: ImplementedFeature = { + name: 'New Feature', + description: 'New description', + }; + const result = addImplementedFeature(baseSpec, newFeature); + expect(result).toContain('Existing Feature'); + expect(result).toContain('New Feature'); + }); + + it('should not add duplicate feature', () => { + const duplicate: ImplementedFeature = { + name: 'Existing Feature', + description: 'Different description', + }; + const result = addImplementedFeature(baseSpec, duplicate); + // Should still have only one instance + const matches = result.match(/Existing Feature/g); + expect(matches).toHaveLength(1); + }); + + it('should be case-insensitive for duplicates', () => { + const duplicate: ImplementedFeature = { + name: 'EXISTING FEATURE', + description: 'Different description', + }; + const result = addImplementedFeature(baseSpec, duplicate); + expect(result).not.toContain('EXISTING FEATURE'); + }); + }); + + describe('removeImplementedFeature', () => { + const baseSpec = ` + + Feature A + Description A + + + Feature B + Description B + + `; + + it('should remove feature by name', () => { + const result = removeImplementedFeature(baseSpec, 'Feature A'); + expect(result).not.toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + + it('should be case-insensitive', () => { + const result = removeImplementedFeature(baseSpec, 'feature a'); + expect(result).not.toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + + it('should return unchanged content if feature not found', () => { + const result = removeImplementedFeature(baseSpec, 'Nonexistent'); + expect(result).toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + }); + + describe('updateImplementedFeature', () => { + const baseSpec = ` + + My Feature + Original description + + `; + + it('should update feature description', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + description: 'Updated description', + }); + expect(result).toContain('Updated description'); + expect(result).not.toContain('Original description'); + }); + + it('should add file_locations', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + file_locations: ['src/new.ts'], + }); + expect(result).toContain(''); + expect(result).toContain('src/new.ts'); + }); + + it('should preserve feature name if not updated', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + description: 'New desc', + }); + expect(result).toContain('My Feature'); + }); + + it('should be case-insensitive', () => { + const result = updateImplementedFeature(baseSpec, 'my feature', { + description: 'Updated', + }); + expect(result).toContain('Updated'); + }); + + it('should return unchanged content if feature not found', () => { + const result = updateImplementedFeature(baseSpec, 'Nonexistent', { + description: 'New', + }); + expect(result).toContain('Original description'); + }); + }); + + describe('hasImplementedFeature', () => { + const baseSpec = ` + + Existing Feature + Description + + `; + + it('should return true for existing feature', () => { + expect(hasImplementedFeature(baseSpec, 'Existing Feature')).toBe(true); + }); + + it('should return false for non-existing feature', () => { + expect(hasImplementedFeature(baseSpec, 'Nonexistent')).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(hasImplementedFeature(baseSpec, 'existing feature')).toBe(true); + expect(hasImplementedFeature(baseSpec, 'EXISTING FEATURE')).toBe(true); + }); + }); + + describe('toSpecOutputFeatures', () => { + it('should convert to SpecOutput format', () => { + const features: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]; + const result = toSpecOutputFeatures(features); + expect(result).toEqual([ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]); + }); + + it('should handle empty array', () => { + expect(toSpecOutputFeatures([])).toEqual([]); + }); + }); + + describe('fromSpecOutputFeatures', () => { + it('should convert from SpecOutput format', () => { + const specFeatures = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]; + const result = fromSpecOutputFeatures(specFeatures); + expect(result).toEqual([ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]); + }); + + it('should handle empty array', () => { + expect(fromSpecOutputFeatures([])).toEqual([]); + }); + }); + + describe('roundtrip', () => { + it('should maintain data integrity through extract -> update cycle', () => { + const originalSpec = ` + + Test + + Testing + + + + Test & Feature + Uses <special> chars + + src/test.ts + + + +`; + + // Extract features + const features = extractImplementedFeatures(originalSpec); + expect(features[0].name).toBe('Test & Feature'); + expect(features[0].description).toBe('Uses chars'); + + // Update with same features + const result = updateImplementedFeaturesSection(originalSpec, features); + + // Re-extract and verify + const reExtracted = extractImplementedFeatures(result); + expect(reExtracted[0].name).toBe('Test & Feature'); + expect(reExtracted[0].description).toBe('Uses chars'); + expect(reExtracted[0].file_locations).toEqual(['src/test.ts']); + }); + }); + + describe('custom logger', () => { + it('should use custom logger for extractXmlSection', () => { + const mockLogger = createMockLogger(); + const xml = '
content
'; + extractXmlSection(xml, 'section', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted
section'); + }); + + it('should log when section is not found', () => { + const mockLogger = createMockLogger(); + const xml = 'content'; + extractXmlSection(xml, 'missing', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Section not found'); + }); + + it('should use custom logger for extractXmlElements', () => { + const mockLogger = createMockLogger(); + const xml = 'onetwo'; + extractXmlElements(xml, 'item', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 2 elements'); + }); + + it('should use custom logger for extractImplementedFeatures', () => { + const mockLogger = createMockLogger(); + const xml = ` + + Test + Desc + + `; + extractImplementedFeatures(xml, { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 1 implemented features'); + }); + + it('should log when no implemented_features section found', () => { + const mockLogger = createMockLogger(); + const xml = 'content'; + extractImplementedFeatures(xml, { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('No implemented_features section found'); + }); + + it('should use custom logger warn for missing insertion point', () => { + const mockLogger = createMockLogger(); + // XML without project_specification, core_capabilities, or implemented_features + const xml = 'content'; + const features: ImplementedFeature[] = [{ name: 'Test', description: 'Desc' }]; + updateImplementedFeaturesSection(xml, features, { logger: mockLogger }); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Could not find appropriate insertion point for implemented_features' + ); + }); + }); + + describe('edge cases', () => { + describe('escapeXml edge cases', () => { + it('should handle strings with only special characters', () => { + expect(escapeXml('<>&"\'')).toBe('<>&"''); + }); + + it('should handle very long strings', () => { + const longString = 'a'.repeat(10000) + '&' + 'b'.repeat(10000); + const escaped = escapeXml(longString); + expect(escaped).toContain('&'); + expect(escaped.length).toBe(20005); // +4 for & minus & + }); + + it('should handle unicode characters without escaping', () => { + const unicode = '日本語 emoji: 🚀 symbols: ∞ ≠ ≤'; + expect(escapeXml(unicode)).toBe(unicode); + }); + }); + + describe('unescapeXml edge cases', () => { + it('should handle strings with only entities', () => { + expect(unescapeXml('<>&"'')).toBe('<>&"\''); + }); + + it('should not double-unescape', () => { + // &lt; should become < (not <) + expect(unescapeXml('&lt;')).toBe('<'); + }); + + it('should handle partial/invalid entities gracefully', () => { + // Invalid entities should pass through unchanged + expect(unescapeXml('&unknown;')).toBe('&unknown;'); + expect(unescapeXml('&')).toBe('&'); // Missing semicolon + }); + }); + + describe('extractXmlSection edge cases', () => { + it('should handle nested tags with same name', () => { + // Note: regex-based parsing with non-greedy matching will match + // from first opening tag to first closing tag + const xml = 'inner'; + // Non-greedy [\s\S]*? matches from first to first + expect(extractXmlSection(xml, 'outer')).toBe('inner'); + }); + + it('should handle self-closing tags (returns null)', () => { + const xml = '
'; + // Regex expects content between tags, self-closing won't match + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should handle tags with attributes', () => { + const xml = '
content
'; + // The regex matches exact tag names, so this won't match + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should handle whitespace in tag content', () => { + const xml = '
\n\t
'; + expect(extractXmlSection(xml, 'section')).toBe(' \n\t '); + }); + }); + + describe('extractXmlElements edge cases', () => { + it('should handle elements across multiple lines', () => { + const xml = ` + + first + + second + `; + // Multiline content is now captured with [\s\S]*? pattern + const result = extractXmlElements(xml, 'item'); + expect(result).toHaveLength(2); + expect(result[0]).toBe('first'); + expect(result[1]).toBe('second'); + }); + + it('should handle consecutive elements without whitespace', () => { + const xml = 'abc'; + expect(extractXmlElements(xml, 'item')).toEqual(['a', 'b', 'c']); + }); + }); + + describe('extractImplementedFeatures edge cases', () => { + it('should skip features without names', () => { + const xml = ` + + Orphan description + + + Valid Feature + Has name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(1); + expect(features[0].name).toBe('Valid Feature'); + }); + + it('should handle features with empty names', () => { + const xml = ` + + + Empty name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(0); // Empty name is falsy + }); + + it('should handle features with whitespace-only names', () => { + const xml = ` + + + Whitespace name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(0); // Trimmed whitespace is empty + }); + + it('should handle empty file_locations section', () => { + const xml = ` + + Test + Desc + + + + `; + const features = extractImplementedFeatures(xml); + expect(features[0].file_locations).toBeUndefined(); + }); + }); + + describe('featureToXml edge cases', () => { + it('should handle custom indentation', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: 'Desc', + }; + const xml = featureToXml(feature, '\t'); + expect(xml).toContain('\t\t'); + expect(xml).toContain('\t\t\tTest'); + }); + + it('should handle empty description', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: '', + }; + const xml = featureToXml(feature); + expect(xml).toContain(''); + }); + + it('should handle undefined file_locations', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: 'Desc', + file_locations: undefined, + }; + const xml = featureToXml(feature); + expect(xml).not.toContain('file_locations'); + }); + }); + + describe('updateImplementedFeaturesSection edge cases', () => { + it('should insert before as fallback', () => { + const specWithoutCoreCapabilities = ` + + Test +`; + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(specWithoutCoreCapabilities, newFeatures); + expect(result).toContain(''); + expect(result).toContain('New Feature'); + expect(result.indexOf('')).toBeLessThan( + result.indexOf('') + ); + }); + + it('should return unchanged content when no insertion point found', () => { + const invalidSpec = 'content'; + const newFeatures: ImplementedFeature[] = [{ name: 'Feature', description: 'Desc' }]; + const result = updateImplementedFeaturesSection(invalidSpec, newFeatures); + expect(result).toBe(invalidSpec); + }); + + it('should handle empty features array', () => { + const spec = ` + + Old + Old desc + + `; + const result = updateImplementedFeaturesSection(spec, []); + expect(result).toContain(''); + expect(result).not.toContain('Old'); + }); + }); + + describe('addImplementedFeature edge cases', () => { + it('should create section when adding to spec without implemented_features', () => { + const specWithoutSection = ` + + Testing + +`; + const newFeature: ImplementedFeature = { + name: 'First Feature', + description: 'First description', + }; + const result = addImplementedFeature(specWithoutSection, newFeature); + expect(result).toContain(''); + expect(result).toContain('First Feature'); + }); + + it('should handle feature with all fields populated', () => { + const spec = ``; + const newFeature: ImplementedFeature = { + name: 'Complete Feature', + description: 'Full description', + file_locations: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }; + const result = addImplementedFeature(spec, newFeature); + expect(result).toContain('Complete Feature'); + expect(result).toContain('src/a.ts'); + expect(result).toContain('src/b.ts'); + expect(result).toContain('src/c.ts'); + }); + }); + + describe('updateImplementedFeature edge cases', () => { + it('should allow updating feature name', () => { + const spec = ` + + Old Name + Desc + + `; + const result = updateImplementedFeature(spec, 'Old Name', { + name: 'New Name', + }); + expect(result).toContain('New Name'); + expect(result).not.toContain('Old Name'); + }); + + it('should allow clearing file_locations', () => { + const spec = ` + + Test + Desc + + src/old.ts + + + `; + const result = updateImplementedFeature(spec, 'Test', { + file_locations: [], + }); + expect(result).not.toContain('file_locations'); + expect(result).not.toContain('src/old.ts'); + }); + + it('should handle updating multiple fields at once', () => { + const spec = ` + + Original + Original desc + + `; + const result = updateImplementedFeature(spec, 'Original', { + name: 'Updated', + description: 'Updated desc', + file_locations: ['new/path.ts'], + }); + expect(result).toContain('Updated'); + expect(result).toContain('Updated desc'); + expect(result).toContain('new/path.ts'); + }); + }); + + describe('toSpecOutputFeatures and fromSpecOutputFeatures edge cases', () => { + it('should handle features with empty file_locations array', () => { + const features: ImplementedFeature[] = [ + { name: 'Test', description: 'Desc', file_locations: [] }, + ]; + const specOutput = toSpecOutputFeatures(features); + expect(specOutput[0].file_locations).toBeUndefined(); + }); + + it('should handle round-trip conversion', () => { + const original: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f.ts'] }, + ]; + const specOutput = toSpecOutputFeatures(original); + const restored = fromSpecOutputFeatures(specOutput); + expect(restored).toEqual(original); + }); + }); + }); + + describe('integration scenarios', () => { + it('should handle a complete spec file workflow', () => { + // Start with a minimal spec + let spec = ` + + My App + + User management + +`; + + // Add first feature + spec = addImplementedFeature(spec, { + name: 'User Authentication', + description: 'Login and logout functionality', + file_locations: ['src/auth/login.ts', 'src/auth/logout.ts'], + }); + expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); + + // Add second feature + spec = addImplementedFeature(spec, { + name: 'User Profile', + description: 'View and edit user profile', + }); + expect(extractImplementedFeatureNames(spec)).toEqual(['User Authentication', 'User Profile']); + + // Update first feature + spec = updateImplementedFeature(spec, 'User Authentication', { + file_locations: ['src/auth/login.ts', 'src/auth/logout.ts', 'src/auth/session.ts'], + }); + const features = extractImplementedFeatures(spec); + expect(features[0].file_locations).toContain('src/auth/session.ts'); + + // Remove a feature + spec = removeImplementedFeature(spec, 'User Profile'); + expect(hasImplementedFeature(spec, 'User Profile')).toBe(false); + expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); + }); + + it('should handle special characters throughout workflow', () => { + const spec = ` + +`; + + const result = addImplementedFeature(spec, { + name: 'Search & Filter', + description: 'Supports syntax with "quoted" terms', + file_locations: ["src/search/parser's.ts"], + }); + + const features = extractImplementedFeatures(result); + expect(features[0].name).toBe('Search & Filter'); + expect(features[0].description).toBe('Supports syntax with "quoted" terms'); + expect(features[0].file_locations?.[0]).toBe("src/search/parser's.ts"); + }); + + it('should preserve other XML content when modifying features', () => { + const spec = ` + + Preserved Name + This should be preserved + + Capability 1 + Capability 2 + + + + Old Feature + Will be replaced + + + Keep this too +`; + + const result = updateImplementedFeaturesSection(spec, [ + { name: 'New Feature', description: 'New desc' }, + ]); + + expect(result).toContain('Preserved Name'); + expect(result).toContain('This should be preserved'); + expect(result).toContain('Capability 1'); + expect(result).toContain('Capability 2'); + expect(result).toContain('Keep this too'); + expect(result).not.toContain('Old Feature'); + expect(result).toContain('New Feature'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 024c4e3a..07ad13c9 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -518,7 +518,11 @@ Resets in 2h const promise = ptyService.fetchUsageData(); - dataCallback!('authentication_error'); + // Send data containing the authentication error pattern the service looks for + dataCallback!('"type":"authentication_error"'); + + // Trigger the exit handler which checks for auth errors + exitCallback!({ exitCode: 1 }); await expect(promise).rejects.toThrow( "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." diff --git a/apps/server/tests/unit/services/feature-loader.test.ts b/apps/server/tests/unit/services/feature-loader.test.ts index dc540982..d70f0326 100644 --- a/apps/server/tests/unit/services/feature-loader.test.ts +++ b/apps/server/tests/unit/services/feature-loader.test.ts @@ -442,4 +442,471 @@ describe('feature-loader.ts', () => { ); }); }); + + describe('findByTitle', () => { + it('should find feature by exact title match (case-insensitive)', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'Login Feature', + category: 'auth', + description: 'Login implementation', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Logout Feature', + category: 'auth', + description: 'Logout implementation', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + expect(result?.title).toBe('Login Feature'); + }); + + it('should return null when title is not found', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'Login Feature', + category: 'auth', + description: 'Login implementation', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature'); + + expect(result).toBeNull(); + }); + + it('should return null for empty or whitespace title', async () => { + const result1 = await loader.findByTitle(testProjectPath, ''); + const result2 = await loader.findByTitle(testProjectPath, ' '); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should skip features without titles', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + // no title + category: 'auth', + description: 'Login implementation', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Login Feature', + category: 'auth', + description: 'Another login', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'Login Feature'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-2000-def'); + }); + }); + + describe('findDuplicateTitle', () => { + it('should find duplicate title', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature description', + }) + ); + + const result = await loader.findDuplicateTitle(testProjectPath, 'my feature'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + + it('should exclude specified feature ID from duplicate check', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Other Feature', + category: 'ui', + description: 'Feature 2', + }) + ); + + // Should not find duplicate when excluding the feature that has the title + const result = await loader.findDuplicateTitle( + testProjectPath, + 'My Feature', + 'feature-1000-abc' + ); + + expect(result).toBeNull(); + }); + + it('should find duplicate when title exists on different feature', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Other Feature', + category: 'ui', + description: 'Feature 2', + }) + ); + + // Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def + const result = await loader.findDuplicateTitle( + testProjectPath, + 'My Feature', + 'feature-2000-def' + ); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + + it('should return null for empty or whitespace title', async () => { + const result1 = await loader.findDuplicateTitle(testProjectPath, ''); + const result2 = await loader.findDuplicateTitle(testProjectPath, ' '); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should handle titles with leading/trailing whitespace', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature description', + }) + ); + + const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature '); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + }); + + describe('syncFeatureToAppSpec', () => { + const sampleAppSpec = ` + + Test Project + + Testing + + + + Existing Feature + Already implemented + + +`; + + const appSpecWithoutFeatures = ` + + Test Project + + Testing + +`; + + it('should add feature to app_spec.txt', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('app_spec.txt'), + expect.stringContaining('New Feature'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('A new feature description'), + 'utf-8' + ); + }); + + it('should add feature with file locations', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature With Locations', + category: 'backend', + description: 'Feature with file locations', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [ + 'src/feature.ts', + 'src/utils/helper.ts', + ]); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('src/feature.ts'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('src/utils/helper.ts'), + 'utf-8' + ); + }); + + it('should return false when app_spec.txt does not exist', async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValueOnce(error); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should return false when feature already exists (duplicate)', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + + const feature = { + id: 'feature-5678-xyz', + title: 'Existing Feature', // Same name as existing feature + category: 'ui', + description: 'Different description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should use feature ID as fallback name when title is missing', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + category: 'ui', + description: 'Feature without title', + // No title property + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Feature: feature-1234-abc'), + 'utf-8' + ); + }); + + it('should handle app_spec without implemented_features section', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'First Feature', + category: 'ui', + description: 'First implemented feature', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining(''), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('First Feature'), + 'utf-8' + ); + }); + + it('should throw on non-ENOENT file read errors', async () => { + const error = new Error('Permission denied'); + vi.mocked(fs.readFile).mockRejectedValueOnce(error); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow( + 'Permission denied' + ); + }); + + it('should preserve existing features when adding a new one', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature', + }; + + await loader.syncFeatureToAppSpec(testProjectPath, feature); + + // Verify both old and new features are in the output + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Existing Feature'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('New Feature'), + 'utf-8' + ); + }); + + it('should escape special characters in feature name and description', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature with & "chars"', + category: 'ui', + description: 'Description with & "quotes"', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + // The XML should have escaped characters + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('<special>'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('&'), + 'utf-8' + ); + }); + + it('should not add empty file_locations array', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature Without Locations', + category: 'ui', + description: 'No file locations', + }; + + await loader.syncFeatureToAppSpec(testProjectPath, feature, []); + + // File locations should not be included when array is empty + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenContent = writeCall[1] as string; + + // Count occurrences of file_locations - should only have the one from Existing Feature if any + // The new feature should not add file_locations + expect(writtenContent).toContain('Feature Without Locations'); + }); + }); }); diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx index 24cdafbf..4f864eea 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -151,7 +151,7 @@ export function SidebarFooter({ sidebarOpen ? 'justify-start' : 'justify-center', 'hover:scale-[1.02] active:scale-[0.97]' )} - title={!sidebarOpen ? 'Settings' : undefined} + title={!sidebarOpen ? 'Global Settings' : undefined} data-testid="settings-button" > - Settings + Global Settings {sidebarOpen && ( - Settings + Global Settings {formatShortcut(shortcuts.settings, true)} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index f1671a78..d95f0c3a 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -41,7 +41,13 @@ export function SidebarNavigation({ )} - {section.label && !sidebarOpen &&
} + {/* Separator for sections without label (visual separation) */} + {!section.label && sectionIdx > 0 && sidebarOpen && ( +
+ )} + {(section.label || sectionIdx > 0) && !sidebarOpen && ( +
+ )} {/* Nav Items */}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index ff8b7b0b..8c712299 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -12,6 +12,7 @@ import { Brain, Network, Bell, + Settings, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -33,6 +34,7 @@ interface UseNavigationProps { agent: string; terminal: string; settings: string; + projectSettings: string; ideation: string; githubIssues: string; githubPrs: string; @@ -218,6 +220,19 @@ export function useNavigation({ ], }); + // Add Project Settings as a standalone section (no label for visual separation) + sections.push({ + label: '', + items: [ + { + id: 'project-settings', + label: 'Project Settings', + icon: Settings, + shortcut: shortcuts.projectSettings, + }, + ], + }); + return sections; }, [ shortcuts, @@ -277,11 +292,11 @@ export function useNavigation({ }); }); - // Add settings shortcut + // Add global settings shortcut shortcutsList.push({ key: shortcuts.settings, action: () => navigate({ to: '/settings' }), - description: 'Navigate to Settings', + description: 'Navigate to Global Settings', }); } diff --git a/apps/ui/src/components/ui/shell-syntax-editor.tsx b/apps/ui/src/components/ui/shell-syntax-editor.tsx index 159123c4..c405309a 100644 --- a/apps/ui/src/components/ui/shell-syntax-editor.tsx +++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx @@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({ backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', }, '.cm-activeLine': { - backgroundColor: 'var(--accent)', - opacity: '0.3', + backgroundColor: 'transparent', }, '.cm-line': { padding: '0 0.25rem', @@ -114,7 +113,7 @@ export function ShellSyntaxEditor({ }: ShellSyntaxEditorProps) { return (
diff --git a/apps/ui/src/components/views/project-settings-view/components/project-settings-navigation.tsx b/apps/ui/src/components/views/project-settings-view/components/project-settings-navigation.tsx new file mode 100644 index 00000000..1c06dad3 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/components/project-settings-navigation.tsx @@ -0,0 +1,122 @@ +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { PROJECT_SETTINGS_NAV_ITEMS } from '../config/navigation'; +import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; + +interface ProjectSettingsNavigationProps { + activeSection: ProjectSettingsViewId; + onNavigate: (sectionId: ProjectSettingsViewId) => void; + isOpen?: boolean; + onClose?: () => void; +} + +export function ProjectSettingsNavigation({ + activeSection, + onNavigate, + isOpen = true, + onClose, +}: ProjectSettingsNavigationProps) { + return ( + <> + {/* Mobile backdrop overlay - only shown when isOpen is true on mobile */} + {isOpen && ( +
+ )} + + {/* Navigation sidebar */} + + + ); +} diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts new file mode 100644 index 00000000..7f052ef5 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -0,0 +1,16 @@ +import type { LucideIcon } from 'lucide-react'; +import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react'; +import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; + +export interface ProjectNavigationItem { + id: ProjectSettingsViewId; + label: string; + icon: LucideIcon; +} + +export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ + { id: 'identity', label: 'Identity', icon: User }, + { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, + { id: 'theme', label: 'Theme', icon: Palette }, + { id: 'danger', label: 'Danger Zone', icon: AlertTriangle }, +]; diff --git a/apps/ui/src/components/views/project-settings-view/hooks/index.ts b/apps/ui/src/components/views/project-settings-view/hooks/index.ts new file mode 100644 index 00000000..023eca9e --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/hooks/index.ts @@ -0,0 +1 @@ +export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view'; diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts new file mode 100644 index 00000000..19faf5e3 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react'; + +export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger'; + +interface UseProjectSettingsViewOptions { + initialView?: ProjectSettingsViewId; +} + +export function useProjectSettingsView({ + initialView = 'identity', +}: UseProjectSettingsViewOptions = {}) { + const [activeView, setActiveView] = useState(initialView); + + const navigateTo = useCallback((viewId: ProjectSettingsViewId) => { + setActiveView(viewId); + }, []); + + return { + activeView, + navigateTo, + }; +} diff --git a/apps/ui/src/components/views/project-settings-view/index.ts b/apps/ui/src/components/views/project-settings-view/index.ts new file mode 100644 index 00000000..bc16ffaf --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/index.ts @@ -0,0 +1,6 @@ +export { ProjectSettingsView } from './project-settings-view'; +export { ProjectIdentitySection } from './project-identity-section'; +export { ProjectThemeSection } from './project-theme-section'; +export { WorktreePreferencesSection } from './worktree-preferences-section'; +export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks'; +export { ProjectSettingsNavigation } from './components/project-settings-navigation'; diff --git a/apps/ui/src/components/views/project-settings-view/project-identity-section.tsx b/apps/ui/src/components/views/project-settings-view/project-identity-section.tsx new file mode 100644 index 00000000..669b7879 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-identity-section.tsx @@ -0,0 +1,225 @@ +import { useState, useRef, useEffect } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Palette, Upload, X, ImageIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import type { Project } from '@/lib/electron'; + +interface ProjectIdentitySectionProps { + project: Project; +} + +export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps) { + const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore(); + const [projectName, setProjectNameLocal] = useState(project.name || ''); + const [projectIcon, setProjectIconLocal] = useState(project.icon || null); + const [customIconPath, setCustomIconPathLocal] = useState( + project.customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(null); + + // Sync local state when project changes + useEffect(() => { + setProjectNameLocal(project.name || ''); + setProjectIconLocal(project.icon || null); + setCustomIconPathLocal(project.customIconPath || null); + }, [project]); + + // Auto-save when values change + const handleNameChange = (name: string) => { + setProjectNameLocal(name); + if (name.trim() && name.trim() !== project.name) { + setProjectName(project.id, name.trim()); + } + }; + + const handleIconChange = (icon: string | null) => { + setProjectIconLocal(icon); + setProjectIcon(project.id, icon); + }; + + const handleCustomIconChange = (path: string | null) => { + setCustomIconPathLocal(path); + setProjectCustomIcon(project.id, path); + // Clear Lucide icon when custom icon is set + if (path) { + setProjectIconLocal(null); + setProjectIcon(project.id, null); + } + }; + + const handleCustomIconUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file type + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + toast.error('Invalid file type', { + description: 'Please upload a PNG, JPG, GIF, or WebP image.', + }); + return; + } + + // Validate file size (max 2MB for icons) + if (file.size > 2 * 1024 * 1024) { + toast.error('File too large', { + description: 'Please upload an image smaller than 2MB.', + }); + return; + } + + setIsUploadingIcon(true); + try { + // Convert to base64 + const reader = new FileReader(); + reader.onload = async () => { + try { + const base64Data = reader.result as string; + const result = await getHttpApiClient().saveImageToTemp( + base64Data, + `project-icon-${file.name}`, + file.type, + project.path + ); + if (result.success && result.path) { + handleCustomIconChange(result.path); + toast.success('Icon uploaded successfully'); + } else { + toast.error('Failed to upload icon', { + description: result.error || 'Please try again.', + }); + } + } catch (error) { + toast.error('Failed to upload icon', { + description: 'Network error. Please try again.', + }); + } finally { + setIsUploadingIcon(false); + } + }; + reader.onerror = () => { + toast.error('Failed to read file', { + description: 'Please try again with a different file.', + }); + setIsUploadingIcon(false); + }; + reader.readAsDataURL(file); + } catch { + toast.error('Failed to upload icon'); + setIsUploadingIcon(false); + } + }; + + const handleRemoveCustomIcon = () => { + handleCustomIconChange(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( +
+
+
+
+ +
+

Project Identity

+
+

+ Customize how your project appears in the sidebar and project switcher. +

+
+
+ {/* Project Name */} +
+ + handleNameChange(e.target.value)} + placeholder="Enter project name" + /> +
+ + {/* Project Icon */} +
+ +

+ Choose a preset icon or upload a custom image +

+ + {/* Custom Icon Upload */} +
+
+ {customIconPath ? ( +
+ Custom project icon + +
+ ) : ( +
+ +
+ )} +
+ + +

+ PNG, JPG, GIF or WebP. Max 2MB. +

+
+
+
+ + {/* Preset Icon Picker - only show if no custom icon */} + {!customIconPath && ( + + )} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx new file mode 100644 index 00000000..f441cc72 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -0,0 +1,174 @@ +import { useState, useEffect } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Settings, FolderOpen, Menu } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ProjectIdentitySection } from './project-identity-section'; +import { ProjectThemeSection } from './project-theme-section'; +import { WorktreePreferencesSection } from './worktree-preferences-section'; +import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; +import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog'; +import { ProjectSettingsNavigation } from './components/project-settings-navigation'; +import { useProjectSettingsView } from './hooks/use-project-settings-view'; +import type { Project as ElectronProject } from '@/lib/electron'; + +// Breakpoint constant for mobile (matches Tailwind lg breakpoint) +const LG_BREAKPOINT = 1024; + +// Convert to the shared types used by components +interface SettingsProject { + id: string; + name: string; + path: string; + theme?: string; + icon?: string | null; + customIconPath?: string | null; +} + +export function ProjectSettingsView() { + const { currentProject, moveProjectToTrash } = useAppStore(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // Use project settings view navigation hook + const { activeView, navigateTo } = useProjectSettingsView(); + + // Mobile navigation state - default to showing on desktop, hidden on mobile + const [showNavigation, setShowNavigation] = useState(() => { + if (typeof window !== 'undefined') { + return window.innerWidth >= LG_BREAKPOINT; + } + return true; + }); + + // Auto-close navigation on mobile when a section is selected + useEffect(() => { + if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) { + setShowNavigation(false); + } + }, [activeView]); + + // Handle window resize to show/hide navigation appropriately + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= LG_BREAKPOINT) { + setShowNavigation(true); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Convert electron Project to settings-view Project type + const convertProject = (project: ElectronProject | null): SettingsProject | null => { + if (!project) return null; + return { + id: project.id, + name: project.name, + path: project.path, + theme: project.theme, + icon: project.icon, + customIconPath: project.customIconPath, + }; + }; + + const settingsProject = convertProject(currentProject); + + // Render the active section based on current view + const renderActiveSection = () => { + if (!currentProject) return null; + + switch (activeView) { + case 'identity': + return ; + case 'theme': + return ; + case 'worktrees': + return ; + case 'danger': + return ( + setShowDeleteDialog(true)} + /> + ); + default: + return ; + } + }; + + // Show message if no project is selected + if (!currentProject) { + return ( +
+
+
+
+ +
+

No Project Selected

+

+ Select a project from the sidebar to configure project-specific settings. +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {/* Mobile menu button */} + + +
+

Project Settings

+

+ Configure settings for {currentProject.name} +

+
+
+
+ + {/* Content Area with Sidebar */} +
+ {/* Side Navigation */} + setShowNavigation(false)} + /> + + {/* Content Panel - Shows only the active section */} +
+
{renderActiveSection()}
+
+
+ + {/* Delete Project Confirmation Dialog */} + +
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx b/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx new file mode 100644 index 00000000..d9293df2 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Palette, Moon, Sun } from 'lucide-react'; +import { darkThemes, lightThemes, type Theme } from '@/config/theme-options'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import type { Project } from '@/lib/electron'; + +interface ProjectThemeSectionProps { + project: Project; +} + +export function ProjectThemeSection({ project }: ProjectThemeSectionProps) { + const { theme: globalTheme, setProjectTheme } = useAppStore(); + const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); + + const projectTheme = project.theme as Theme | undefined; + const hasCustomTheme = projectTheme !== undefined; + const effectiveTheme = projectTheme || globalTheme; + + const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; + + const handleThemeChange = (theme: Theme) => { + setProjectTheme(project.id, theme); + }; + + const handleUseGlobalTheme = (checked: boolean) => { + if (checked) { + // Clear project theme to use global + setProjectTheme(project.id, null); + } else { + // Set project theme to current global theme + setProjectTheme(project.id, globalTheme); + } + }; + + return ( +
+
+
+
+ +
+

Theme

+
+

+ Customize the theme for this project. +

+
+
+ {/* Use Global Theme Toggle */} +
+ +
+ +

+ When enabled, this project will use the global theme setting. Disable to set a + project-specific theme. +

+
+
+ + {/* Theme Selection - only show if not using global theme */} + {hasCustomTheme && ( +
+
+ + {/* Dark/Light Tabs */} +
+ + +
+
+
+ {themesToShow.map(({ value, label, Icon, testId, color }) => { + const isActive = effectiveTheme === value; + return ( + + ); + })} +
+
+ )} + + {/* Info when using global theme */} + {!hasCustomTheme && ( +
+

+ This project is using the global theme:{' '} + {globalTheme} +

+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx new file mode 100644 index 00000000..c289d382 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx @@ -0,0 +1,478 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; +import { + GitBranch, + Terminal, + FileCode, + Save, + RotateCcw, + Trash2, + Loader2, + PanelBottomClose, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import type { Project } from '@/lib/electron'; + +interface WorktreePreferencesSectionProps { + project: Project; +} + +interface InitScriptResponse { + success: boolean; + exists: boolean; + content: string; + path: string; + error?: string; +} + +export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) { + const globalUseWorktrees = useAppStore((s) => s.useWorktrees); + const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees); + const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees); + const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); + const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); + const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch); + const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); + const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); + const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); + + // Get effective worktrees setting (project override or global fallback) + const projectUseWorktrees = getProjectUseWorktrees(project.path); + const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees; + + const [scriptContent, setScriptContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [scriptExists, setScriptExists] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Get the current settings for this project + const showIndicator = getShowInitScriptIndicator(project.path); + const defaultDeleteBranch = getDefaultDeleteBranch(project.path); + const autoDismiss = getAutoDismissInitScriptIndicator(project.path); + + // Check if there are unsaved changes + const hasChanges = scriptContent !== originalContent; + + // Load project settings (including useWorktrees) when project changes + useEffect(() => { + let isCancelled = false; + const currentPath = project.path; + + const loadProjectSettings = async () => { + try { + const httpClient = getHttpApiClient(); + const response = await httpClient.settings.getProject(currentPath); + + // Avoid updating state if component unmounted or project changed + if (isCancelled) return; + + if (response.success && response.settings) { + // Sync useWorktrees to store if it has a value + if (response.settings.useWorktrees !== undefined) { + setProjectUseWorktrees(currentPath, response.settings.useWorktrees); + } + // Also sync other settings to store + if (response.settings.showInitScriptIndicator !== undefined) { + setShowInitScriptIndicator(currentPath, response.settings.showInitScriptIndicator); + } + if (response.settings.defaultDeleteBranchWithWorktree !== undefined) { + setDefaultDeleteBranch(currentPath, response.settings.defaultDeleteBranchWithWorktree); + } + if (response.settings.autoDismissInitScriptIndicator !== undefined) { + setAutoDismissInitScriptIndicator( + currentPath, + response.settings.autoDismissInitScriptIndicator + ); + } + } + } catch (error) { + if (!isCancelled) { + console.error('Failed to load project settings:', error); + } + } + }; + + loadProjectSettings(); + + return () => { + isCancelled = true; + }; + }, [ + project.path, + setProjectUseWorktrees, + setShowInitScriptIndicator, + setDefaultDeleteBranch, + setAutoDismissInitScriptIndicator, + ]); + + // Load init script content when project changes + useEffect(() => { + let isCancelled = false; + const currentPath = project.path; + + const loadInitScript = async () => { + setIsLoading(true); + try { + const response = await apiGet( + `/api/worktree/init-script?projectPath=${encodeURIComponent(currentPath)}` + ); + + // Avoid updating state if component unmounted or project changed + if (isCancelled) return; + + if (response.success) { + const content = response.content || ''; + setScriptContent(content); + setOriginalContent(content); + setScriptExists(response.exists); + } + } catch (error) { + if (!isCancelled) { + console.error('Failed to load init script:', error); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + loadInitScript(); + + return () => { + isCancelled = true; + }; + }, [project.path]); + + // Save script + const handleSave = useCallback(async () => { + setIsSaving(true); + try { + const response = await apiPut<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + projectPath: project.path, + content: scriptContent, + } + ); + if (response.success) { + setOriginalContent(scriptContent); + setScriptExists(true); + toast.success('Init script saved'); + } else { + toast.error('Failed to save init script', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to save init script:', error); + toast.error('Failed to save init script'); + } finally { + setIsSaving(false); + } + }, [project.path, scriptContent]); + + // Reset to original content + const handleReset = useCallback(() => { + setScriptContent(originalContent); + }, [originalContent]); + + // Delete script + const handleDelete = useCallback(async () => { + setIsDeleting(true); + try { + const response = await apiDelete<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + body: { projectPath: project.path }, + } + ); + if (response.success) { + setScriptContent(''); + setOriginalContent(''); + setScriptExists(false); + toast.success('Init script deleted'); + } else { + toast.error('Failed to delete init script', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to delete init script:', error); + toast.error('Failed to delete init script'); + } finally { + setIsDeleting(false); + } + }, [project.path]); + + // Handle content change (no auto-save) + const handleContentChange = useCallback((value: string) => { + setScriptContent(value); + }, []); + + return ( +
+
+
+
+ +
+

+ Worktree Preferences +

+
+

+ Configure worktree behavior for this project. +

+
+
+ {/* Enable Git Worktree Isolation Toggle */} +
+ { + const value = checked === true; + setProjectUseWorktrees(project.path, value); + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(project.path, { + useWorktrees: value, + }); + } catch (error) { + console.error('Failed to persist useWorktrees:', error); + } + }} + className="mt-1" + data-testid="project-use-worktrees-checkbox" + /> +
+ +

+ Creates isolated git branches for each feature in this project. When disabled, agents + work directly in the main project directory. +

+
+
+ + {/* Separator */} +
+ + {/* Show Init Script Indicator Toggle */} +
+ { + const value = checked === true; + setShowInitScriptIndicator(project.path, value); + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(project.path, { + showInitScriptIndicator: value, + }); + } catch (error) { + console.error('Failed to persist showInitScriptIndicator:', error); + } + }} + className="mt-1" + /> +
+ +

+ Display a floating panel in the bottom-right corner showing init script execution + status and output when a worktree is created. +

+
+
+ + {/* Auto-dismiss Init Script Indicator Toggle */} + {showIndicator && ( +
+ { + const value = checked === true; + setAutoDismissInitScriptIndicator(project.path, value); + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(project.path, { + autoDismissInitScriptIndicator: value, + }); + } catch (error) { + console.error('Failed to persist autoDismissInitScriptIndicator:', error); + } + }} + className="mt-1" + /> +
+ +

+ Automatically hide the indicator 5 seconds after the script completes. +

+
+
+ )} + + {/* Default Delete Branch Toggle */} +
+ { + const value = checked === true; + setDefaultDeleteBranch(project.path, value); + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(project.path, { + defaultDeleteBranch: value, + }); + } catch (error) { + console.error('Failed to persist defaultDeleteBranch:', error); + } + }} + className="mt-1" + /> +
+ +

+ When deleting a worktree, automatically check the "Also delete the branch" option. +

+
+
+ + {/* Separator */} +
+ + {/* Init Script Section */} +
+
+
+ + +
+
+

+ Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash + on Windows for cross-platform compatibility. +

+ + {/* File path indicator */} +
+ + .automaker/worktree-init.sh + {hasChanges && (unsaved changes)} +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + + + {/* Action buttons */} +
+ + + +
+ + )} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 1ddf0a39..3bcec3bb 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks'; import { NAV_ITEMS } from './settings-view/config/navigation'; import { SettingsHeader } from './settings-view/components/settings-header'; import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog'; -import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog'; import { SettingsNavigation } from './settings-view/components/settings-navigation'; import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ModelDefaultsSection } from './settings-view/model-defaults'; @@ -16,7 +15,6 @@ import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; import { WorktreesSection } from './settings-view/worktrees'; -import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { AccountSection } from './settings-view/account'; import { SecuritySection } from './settings-view/security'; import { DeveloperSection } from './settings-view/developer/developer-section'; @@ -30,8 +28,7 @@ import { MCPServersSection } from './settings-view/mcp-servers'; import { PromptCustomizationSection } from './settings-view/prompts'; import { EventHooksSection } from './settings-view/event-hooks'; import { ImportExportDialog } from './settings-view/components/import-export-dialog'; -import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; -import type { Project as ElectronProject } from '@/lib/electron'; +import type { Theme } from './settings-view/shared/types'; // Breakpoint constant for mobile (matches Tailwind lg breakpoint) const LG_BREAKPOINT = 1024; @@ -40,7 +37,6 @@ export function SettingsView() { const { theme, setTheme, - setProjectTheme, defaultSkipTests, setDefaultSkipTests, enableDependencyBlocking, @@ -54,7 +50,6 @@ export function SettingsView() { muteDoneSound, setMuteDoneSound, currentProject, - moveProjectToTrash, defaultPlanningMode, setDefaultPlanningMode, defaultRequirePlanApproval, @@ -69,34 +64,8 @@ export function SettingsView() { setSkipSandboxWarning, } = useAppStore(); - // Convert electron Project to settings-view Project type - const convertProject = (project: ElectronProject | null): SettingsProject | null => { - if (!project) return null; - return { - id: project.id, - name: project.name, - path: project.path, - theme: project.theme as Theme | undefined, - icon: project.icon, - customIconPath: project.customIconPath, - }; - }; - - const settingsProject = convertProject(currentProject); - - // Compute the effective theme for the current project - const effectiveTheme = (settingsProject?.theme || theme) as Theme; - - // Handler to set theme - always updates global theme (user's preference), - // and also sets per-project theme if a project is selected - const handleSetTheme = (newTheme: typeof theme) => { - // Always update global theme so user's preference persists across all projects - setTheme(newTheme); - // Also set per-project theme if a project is selected - if (currentProject) { - setProjectTheme(currentProject.id, newTheme); - } - }; + // Global theme (project-specific themes are managed in Project Settings) + const globalTheme = theme as Theme; // Get initial view from URL search params const { view: initialView } = useSearch({ from: '/settings' }); @@ -113,7 +82,6 @@ export function SettingsView() { } }; - const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); const [showImportExportDialog, setShowImportExportDialog] = useState(false); @@ -172,9 +140,8 @@ export function SettingsView() { case 'appearance': return ( handleSetTheme(theme as any)} + effectiveTheme={globalTheme} + onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)} /> ); case 'terminal': @@ -223,13 +190,6 @@ export function SettingsView() { ); case 'developer': return ; - case 'danger': - return ( - setShowDeleteDialog(true)} - /> - ); default: return ; } @@ -265,14 +225,6 @@ export function SettingsView() { {/* Keyboard Map Dialog */} - {/* Delete Project Confirmation Dialog */} - - {/* Import/Export Settings Dialog */}
diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index 003501f9..47646287 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -1,118 +1,20 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState } from 'react'; import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react'; +import { Palette, Moon, Sun } from 'lucide-react'; import { darkThemes, lightThemes } from '@/config/theme-options'; import { cn } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; -import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker'; -import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { Theme, Project } from '../shared/types'; +import type { Theme } from '../shared/types'; interface AppearanceSectionProps { effectiveTheme: Theme; - currentProject: Project | null; onThemeChange: (theme: Theme) => void; } -export function AppearanceSection({ - effectiveTheme, - currentProject, - onThemeChange, -}: AppearanceSectionProps) { - const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore(); +export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) { const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); - const [projectName, setProjectNameLocal] = useState(currentProject?.name || ''); - const [projectIcon, setProjectIconLocal] = useState(currentProject?.icon || null); - const [customIconPath, setCustomIconPathLocal] = useState( - currentProject?.customIconPath || null - ); - const [isUploadingIcon, setIsUploadingIcon] = useState(false); - const fileInputRef = useRef(null); - - // Sync local state when currentProject changes - useEffect(() => { - setProjectNameLocal(currentProject?.name || ''); - setProjectIconLocal(currentProject?.icon || null); - setCustomIconPathLocal(currentProject?.customIconPath || null); - }, [currentProject]); const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; - // Auto-save when values change - const handleNameChange = (name: string) => { - setProjectNameLocal(name); - if (currentProject && name.trim() && name.trim() !== currentProject.name) { - setProjectName(currentProject.id, name.trim()); - } - }; - - const handleIconChange = (icon: string | null) => { - setProjectIconLocal(icon); - if (currentProject) { - setProjectIcon(currentProject.id, icon); - } - }; - - const handleCustomIconChange = (path: string | null) => { - setCustomIconPathLocal(path); - if (currentProject) { - setProjectCustomIcon(currentProject.id, path); - // Clear Lucide icon when custom icon is set - if (path) { - setProjectIconLocal(null); - setProjectIcon(currentProject.id, null); - } - } - }; - - const handleCustomIconUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file || !currentProject) return; - - // Validate file type - const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; - if (!validTypes.includes(file.type)) { - return; - } - - // Validate file size (max 2MB for icons) - if (file.size > 2 * 1024 * 1024) { - return; - } - - setIsUploadingIcon(true); - try { - // Convert to base64 - const reader = new FileReader(); - reader.onload = async () => { - const base64Data = reader.result as string; - const result = await getHttpApiClient().saveImageToTemp( - base64Data, - `project-icon-${file.name}`, - file.type, - currentProject.path - ); - if (result.success && result.path) { - handleCustomIconChange(result.path); - } - setIsUploadingIcon(false); - }; - reader.readAsDataURL(file); - } catch { - setIsUploadingIcon(false); - } - }; - - const handleRemoveCustomIcon = () => { - handleCustomIconChange(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - return (
- {/* Project Details Section */} - {currentProject && ( -
-
-
- - handleNameChange(e.target.value)} - placeholder="Enter project name" - /> -
- -
- -

- Choose a preset icon or upload a custom image -

- - {/* Custom Icon Upload */} -
-
- {customIconPath ? ( -
- Custom project icon - -
- ) : ( -
- -
- )} -
- - -

- PNG, JPG, GIF or WebP. Max 2MB. -

-
-
-
- - {/* Preset Icon Picker - only show if no custom icon */} - {!customIconPath && ( - - )} -
-
-
- )} - {/* Theme Section */}
- + {/* Dark/Light Tabs */}
))} - - {/* Project Settings - only show when a project is selected */} - {currentProject && ( - <> - {/* Divider */} -
- - {/* Project Settings Label */} -
- Project Settings -
- - {/* Project Settings Items */} -
- {PROJECT_NAV_ITEMS.map((item) => ( - - ))} -
- - )}
diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index c5d5d362..107d8678 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -8,13 +8,11 @@ import { Settings2, Volume2, FlaskConical, - Trash2, Workflow, Plug, MessageSquareText, User, Shield, - Cpu, GitBranch, Code2, Webhook, @@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ // Flat list of all global nav items for backwards compatibility export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items); -// Project-specific settings - only visible when a project is selected -export const PROJECT_NAV_ITEMS: NavigationItem[] = [ - { id: 'danger', label: 'Danger Zone', icon: Trash2 }, -]; - // Legacy export for backwards compatibility -export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS]; +export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS; diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx index 2d232a65..062d2d0d 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -1,172 +1,14 @@ -import { useState, useEffect, useCallback } from 'react'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; -import { Button } from '@/components/ui/button'; -import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; -import { - GitBranch, - Terminal, - FileCode, - Save, - RotateCcw, - Trash2, - Loader2, - PanelBottomClose, -} from 'lucide-react'; +import { GitBranch } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; -import { toast } from 'sonner'; -import { useAppStore } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; interface WorktreesSectionProps { useWorktrees: boolean; onUseWorktreesChange: (value: boolean) => void; } -interface InitScriptResponse { - success: boolean; - exists: boolean; - content: string; - path: string; - error?: string; -} - export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { - const currentProject = useAppStore((s) => s.currentProject); - const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); - const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); - const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch); - const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); - const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); - const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); - const [scriptContent, setScriptContent] = useState(''); - const [originalContent, setOriginalContent] = useState(''); - const [scriptExists, setScriptExists] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - // Get the current show indicator setting - const showIndicator = currentProject?.path - ? getShowInitScriptIndicator(currentProject.path) - : true; - - // Get the default delete branch setting - const defaultDeleteBranch = currentProject?.path - ? getDefaultDeleteBranch(currentProject.path) - : false; - - // Get the auto-dismiss setting - const autoDismiss = currentProject?.path - ? getAutoDismissInitScriptIndicator(currentProject.path) - : true; - - // Check if there are unsaved changes - const hasChanges = scriptContent !== originalContent; - - // Load init script content when project changes - useEffect(() => { - if (!currentProject?.path) { - setScriptContent(''); - setOriginalContent(''); - setScriptExists(false); - setIsLoading(false); - return; - } - - const loadInitScript = async () => { - setIsLoading(true); - try { - const response = await apiGet( - `/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}` - ); - if (response.success) { - const content = response.content || ''; - setScriptContent(content); - setOriginalContent(content); - setScriptExists(response.exists); - } - } catch (error) { - console.error('Failed to load init script:', error); - } finally { - setIsLoading(false); - } - }; - - loadInitScript(); - }, [currentProject?.path]); - - // Save script - const handleSave = useCallback(async () => { - if (!currentProject?.path) return; - - setIsSaving(true); - try { - const response = await apiPut<{ success: boolean; error?: string }>( - '/api/worktree/init-script', - { - projectPath: currentProject.path, - content: scriptContent, - } - ); - if (response.success) { - setOriginalContent(scriptContent); - setScriptExists(true); - toast.success('Init script saved'); - } else { - toast.error('Failed to save init script', { - description: response.error, - }); - } - } catch (error) { - console.error('Failed to save init script:', error); - toast.error('Failed to save init script'); - } finally { - setIsSaving(false); - } - }, [currentProject?.path, scriptContent]); - - // Reset to original content - const handleReset = useCallback(() => { - setScriptContent(originalContent); - }, [originalContent]); - - // Delete script - const handleDelete = useCallback(async () => { - if (!currentProject?.path) return; - - setIsDeleting(true); - try { - const response = await apiDelete<{ success: boolean; error?: string }>( - '/api/worktree/init-script', - { - body: { projectPath: currentProject.path }, - } - ); - if (response.success) { - setScriptContent(''); - setOriginalContent(''); - setScriptExists(false); - toast.success('Init script deleted'); - } else { - toast.error('Failed to delete init script', { - description: response.error, - }); - } - } catch (error) { - console.error('Failed to delete init script:', error); - toast.error('Failed to delete init script'); - } finally { - setIsDeleting(false); - } - }, [currentProject?.path]); - - // Handle content change (no auto-save) - const handleContentChange = useCallback((value: string) => { - setScriptContent(value); - }, []); - return (
Worktrees

- Configure git worktree isolation and initialization scripts. + Configure git worktree isolation for feature development.

@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
- {/* Show Init Script Indicator Toggle */} - {currentProject && ( -
- { - if (currentProject?.path) { - const value = checked === true; - setShowInitScriptIndicator(currentProject.path, value); - // Persist to server - try { - const httpClient = getHttpApiClient(); - await httpClient.settings.updateProject(currentProject.path, { - showInitScriptIndicator: value, - }); - } catch (error) { - console.error('Failed to persist showInitScriptIndicator:', error); - } - } - }} - className="mt-1" - /> -
- -

- Display a floating panel in the bottom-right corner showing init script execution - status and output when a worktree is created. -

-
-
- )} - - {/* Auto-dismiss Init Script Indicator Toggle */} - {currentProject && showIndicator && ( -
- { - if (currentProject?.path) { - const value = checked === true; - setAutoDismissInitScriptIndicator(currentProject.path, value); - // Persist to server - try { - const httpClient = getHttpApiClient(); - await httpClient.settings.updateProject(currentProject.path, { - autoDismissInitScriptIndicator: value, - }); - } catch (error) { - console.error('Failed to persist autoDismissInitScriptIndicator:', error); - } - } - }} - className="mt-1" - /> -
- -

- Automatically hide the indicator 5 seconds after the script completes. -

-
-
- )} - - {/* Default Delete Branch Toggle */} - {currentProject && ( -
- { - if (currentProject?.path) { - const value = checked === true; - setDefaultDeleteBranch(currentProject.path, value); - // Persist to server - try { - const httpClient = getHttpApiClient(); - await httpClient.settings.updateProject(currentProject.path, { - defaultDeleteBranch: value, - }); - } catch (error) { - console.error('Failed to persist defaultDeleteBranch:', error); - } - } - }} - className="mt-1" - /> -
- -

- When deleting a worktree, automatically check the "Also delete the branch" option. -

-
-
- )} - - {/* Separator */} -
- - {/* Init Script Section */} -
-
-
- - -
-
-

- Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash - on Windows for cross-platform compatibility. + {/* Info about project-specific settings */} +

+

+ Project-specific worktree preferences (init script, delete branch behavior) can be + configured in each project's settings via the sidebar.

- - {currentProject ? ( - <> - {/* File path indicator */} -
- - .automaker/worktree-init.sh - {hasChanges && ( - (unsaved changes) - )} -
- - {isLoading ? ( -
- -
- ) : ( - <> - - - {/* Action buttons */} -
- - - -
- - )} - - ) : ( -
- Select a project to configure the init script. -
- )}
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index f8a12c14..3d40883e 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2175,6 +2175,9 @@ export class HttpApiClient implements ElectronAPI { hideScrollbar: boolean; }; worktreePanelVisible?: boolean; + showInitScriptIndicator?: boolean; + defaultDeleteBranchWithWorktree?: boolean; + autoDismissInitScriptIndicator?: boolean; lastSelectedSessionId?: string; }; error?: string; diff --git a/apps/ui/src/routes/project-settings.tsx b/apps/ui/src/routes/project-settings.tsx new file mode 100644 index 00000000..e933d58d --- /dev/null +++ b/apps/ui/src/routes/project-settings.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { ProjectSettingsView } from '@/components/views/project-settings-view'; + +export const Route = createFileRoute('/project-settings')({ + component: ProjectSettingsView, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 8fcbd203..b05e6697 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -231,6 +231,7 @@ export interface KeyboardShortcuts { context: string; memory: string; settings: string; + projectSettings: string; terminal: string; ideation: string; notifications: string; @@ -267,6 +268,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: 'C', memory: 'Y', settings: 'S', + projectSettings: 'Shift+S', terminal: 'T', ideation: 'I', notifications: 'X', @@ -732,6 +734,10 @@ export interface AppState { // Whether to auto-dismiss the indicator after completion (default: true) autoDismissInitScriptIndicatorByProject: Record; + // Use Worktrees Override (per-project, keyed by project path) + // undefined = use global setting, true/false = project-specific override + useWorktreesByProject: Record; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -1185,6 +1191,11 @@ export interface AppActions { setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void; getAutoDismissInitScriptIndicator: (projectPath: string) => boolean; + // Use Worktrees Override actions (per-project) + setProjectUseWorktrees: (projectPath: string, useWorktrees: boolean | null) => void; // null = use global + getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global + getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback) + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1345,6 +1356,7 @@ const initialState: AppState = { showInitScriptIndicatorByProject: {}, defaultDeleteBranchByProject: {}, autoDismissInitScriptIndicatorByProject: {}, + useWorktreesByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -3528,6 +3540,31 @@ export const useAppStore = create()((set, get) => ({ return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true; }, + // Use Worktrees Override actions (per-project) + setProjectUseWorktrees: (projectPath, useWorktrees) => { + const newValue = useWorktrees === null ? undefined : useWorktrees; + set({ + useWorktreesByProject: { + ...get().useWorktreesByProject, + [projectPath]: newValue, + }, + }); + }, + + getProjectUseWorktrees: (projectPath) => { + // Returns undefined if using global setting, true/false if project-specific + return get().useWorktreesByProject[projectPath]; + }, + + getEffectiveUseWorktrees: (projectPath) => { + // Returns the actual value to use (project override or global fallback) + const projectSetting = get().useWorktreesByProject[projectPath]; + if (projectSetting !== undefined) { + return projectSetting; + } + return get().useWorktrees; + }, + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index ee8a77a3..844abf1e 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -299,6 +299,8 @@ export interface KeyboardShortcuts { context: string; /** Open settings */ settings: string; + /** Open project settings */ + projectSettings: string; /** Open terminal */ terminal: string; /** Open notifications */ @@ -804,6 +806,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { spec: 'D', context: 'C', settings: 'S', + projectSettings: 'Shift+S', terminal: 'T', notifications: 'X', toggleSidebar: '`', diff --git a/package-lock.json b/package-lock.json index 1f9e8037..dd96e672 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "automaker", - "version": "0.12.0rc", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "automaker", - "version": "0.12.0rc", + "version": "1.0.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -29,7 +29,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.12.0", + "version": "0.10.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -80,7 +80,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.12.0", + "version": "0.10.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -11607,7 +11607,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11628,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11671,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11692,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11717,7 +11713,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11739,7 +11734,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11761,7 +11755,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11783,7 +11776,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11805,7 +11797,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" },