Merge branch 'v0.12.0rc' of github.com:AutoMaker-Org/automaker into v0.12.0rc

This commit is contained in:
webdevcody
2026-01-16 18:39:31 -05:00
32 changed files with 3467 additions and 669 deletions

View File

@@ -13,7 +13,7 @@ export { specOutputSchema } from '@automaker/types';
* Escape special XML characters * Escape special XML characters
* Handles undefined/null values by converting them to empty strings * 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) { if (str == null) {
return ''; return '';
} }

View File

@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML entities back to regular characters
*/
export function unescapeXml(str: string): string {
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/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 <implemented_features>...</implemented_features> 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 = /<feature>([\s\S]*?)<\/feature>/g;
const featureMatches = implementedSection.matchAll(featureRegex);
for (const featureMatch of featureMatches) {
const featureContent = featureMatch[1];
// Extract name
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
// Extract description
const descMatch = featureContent.match(/<description>([\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}<feature>
${i3}<name>${escapeXml(feature.name)}</name>
${i3}<description>${escapeXml(feature.description)}</description>`;
if (feature.file_locations && feature.file_locations.length > 0) {
xml += `
${i3}<file_locations>
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
${i3}</file_locations>`;
}
xml += `
${i2}</feature>`;
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 = `<implemented_features>
${newSectionContent}
${indent}</implemented_features>`;
// Check if section exists
const sectionRegex = /<implemented_features>[\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 = '</core_capabilities>';
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 </project_specification>
const projectSpecEnd = '</project_specification>';
const fallbackIndex = specContent.indexOf(projectSpecEnd);
if (fallbackIndex !== -1) {
log.debug('Inserting implemented_features before </project_specification>');
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<ImplementedFeature>,
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 }
: {}),
}));
}

View File

@@ -24,6 +24,19 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
return; 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); const created = await featureLoader.create(projectPath, feature);
// Emit feature_created event for hooks // Emit feature_created event for hooks

View File

@@ -4,8 +4,14 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js'; 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 { 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) { export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return; 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( const updated = await featureLoader.update(
projectPath, projectPath,
featureId, featureId,
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
enhancementMode, enhancementMode,
preEnhancementDescription 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 }); res.json({ success: true, feature: updated });
} catch (error) { } catch (error) {
logError(error, 'Update feature failed'); logError(error, 'Update feature failed');

View File

@@ -2125,6 +2125,16 @@ Format your response as a structured markdown document.`;
projectPath, 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 { } catch {
// Feature file may not exist // Feature file may not exist
} }

View File

@@ -11,8 +11,10 @@ import {
getFeaturesDir, getFeaturesDir,
getFeatureDir, getFeatureDir,
getFeatureImagesDir, getFeatureImagesDir,
getAppSpecPath,
ensureAutomakerDir, ensureAutomakerDir,
} from '@automaker/platform'; } from '@automaker/platform';
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
const logger = createLogger('FeatureLoader'); 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<Feature | null> {
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<Feature | null> {
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 * 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<boolean> {
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;
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -518,7 +518,11 @@ Resets in 2h
const promise = ptyService.fetchUsageData(); 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( await expect(promise).rejects.toThrow(
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."

View File

@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Existing Feature</name>
<description>Already implemented</description>
</feature>
</implemented_features>
</project_specification>`;
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
</project_specification>`;
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('<implemented_features>'),
'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 <special> & "chars"',
category: 'ui',
description: 'Description with <tags> & "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('&lt;special&gt;'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&amp;'),
'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');
});
});
}); });

View File

@@ -151,7 +151,7 @@ export function SidebarFooter({
sidebarOpen ? 'justify-start' : 'justify-center', sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]' 'hover:scale-[1.02] active:scale-[0.97]'
)} )}
title={!sidebarOpen ? 'Settings' : undefined} title={!sidebarOpen ? 'Global Settings' : undefined}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings <Settings
@@ -168,7 +168,7 @@ export function SidebarFooter({
sidebarOpen ? 'block' : 'hidden' sidebarOpen ? 'block' : 'hidden'
)} )}
> >
Settings Global Settings
</span> </span>
{sidebarOpen && ( {sidebarOpen && (
<span <span
@@ -194,7 +194,7 @@ export function SidebarFooter({
'translate-x-1 group-hover:translate-x-0' 'translate-x-1 group-hover:translate-x-0'
)} )}
> >
Settings Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"> <span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)} {formatShortcut(shortcuts.settings, true)}
</span> </span>

View File

@@ -41,7 +41,13 @@ export function SidebarNavigation({
</span> </span>
</div> </div>
)} )}
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>} {/* Separator for sections without label (visual separation) */}
{!section.label && sectionIdx > 0 && sidebarOpen && (
<div className="h-px bg-border/40 mx-3 mb-4"></div>
)}
{(section.label || sectionIdx > 0) && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{/* Nav Items */} {/* Nav Items */}
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -12,6 +12,7 @@ import {
Brain, Brain,
Network, Network,
Bell, Bell,
Settings,
} from 'lucide-react'; } from 'lucide-react';
import type { NavSection, NavItem } from '../types'; import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -33,6 +34,7 @@ interface UseNavigationProps {
agent: string; agent: string;
terminal: string; terminal: string;
settings: string; settings: string;
projectSettings: string;
ideation: string; ideation: string;
githubIssues: string; githubIssues: string;
githubPrs: 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; return sections;
}, [ }, [
shortcuts, shortcuts,
@@ -277,11 +292,11 @@ export function useNavigation({
}); });
}); });
// Add settings shortcut // Add global settings shortcut
shortcutsList.push({ shortcutsList.push({
key: shortcuts.settings, key: shortcuts.settings,
action: () => navigate({ to: '/settings' }), action: () => navigate({ to: '/settings' }),
description: 'Navigate to Settings', description: 'Navigate to Global Settings',
}); });
} }

View File

@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
}, },
'.cm-activeLine': { '.cm-activeLine': {
backgroundColor: 'var(--accent)', backgroundColor: 'transparent',
opacity: '0.3',
}, },
'.cm-line': { '.cm-line': {
padding: '0 0.25rem', padding: '0 0.25rem',
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
}: ShellSyntaxEditorProps) { }: ShellSyntaxEditorProps) {
return ( return (
<div <div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)} className={cn('w-full rounded-lg border border-border bg-background', className)}
style={{ minHeight }} style={{ minHeight }}
data-testid={testId} data-testid={testId}
> >

View File

@@ -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 && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={onClose}
data-testid="project-settings-nav-backdrop"
/>
)}
{/* Navigation sidebar */}
<nav
className={cn(
// Mobile: fixed position overlay with slide transition
'fixed inset-y-0 left-0 w-72 z-30',
'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : '-translate-x-full',
// Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto',
'border-r border-border/50',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
)}
>
{/* Mobile close button */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
<span className="text-sm font-semibold text-foreground">Navigation</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close navigation menu"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="sticky top-0 p-4 space-y-1">
{PROJECT_SETTINGS_NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
const isDanger = item.id === 'danger';
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={cn(
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
isActive
? [
isDanger
? 'bg-gradient-to-r from-red-500/15 via-red-500/10 to-red-600/5'
: 'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
'text-foreground',
isDanger ? 'border border-red-500/25' : 'border border-brand-500/25',
isDanger ? 'shadow-sm shadow-red-500/5' : 'shadow-sm shadow-brand-500/5',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
],
'hover:scale-[1.01] active:scale-[0.98]'
)}
>
{/* Active indicator bar */}
{isActive && (
<div
className={cn(
'absolute inset-y-0 left-0 w-0.5 rounded-r-full',
isDanger
? 'bg-gradient-to-b from-red-400 via-red-500 to-red-600'
: 'bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600'
)}
/>
)}
<Icon
className={cn(
'w-4 h-4 shrink-0 transition-all duration-200',
isActive
? isDanger
? 'text-red-500'
: 'text-brand-500'
: isDanger
? 'group-hover:text-red-400 group-hover:scale-110'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span className={cn(isDanger && !isActive && 'text-red-400/70')}>{item.label}</span>
</button>
);
})}
</div>
</nav>
</>
);
}

View File

@@ -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 },
];

View File

@@ -0,0 +1 @@
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';

View File

@@ -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<ProjectSettingsViewId>(initialView);
const navigateTo = useCallback((viewId: ProjectSettingsViewId) => {
setActiveView(viewId);
}, []);
return {
activeView,
navigateTo,
};
}

View File

@@ -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';

View File

@@ -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<string | null>(project.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
project.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Palette className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Identity</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize how your project appears in the sidebar and project switcher.
</p>
</div>
<div className="p-6 space-y-6">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
{/* Project Icon */}
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, project.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
);
}

View File

@@ -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 <ProjectIdentitySection project={currentProject} />;
case 'theme':
return <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default:
return <ProjectIdentitySection project={currentProject} />;
}
};
// Show message if no project is selected
if (!currentProject) {
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="project-settings-view"
>
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-muted/50 flex items-center justify-center">
<FolderOpen className="w-8 h-8 text-muted-foreground/50" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">No Project Selected</h2>
<p className="text-sm text-muted-foreground">
Select a project from the sidebar to configure project-specific settings.
</p>
</div>
</div>
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="project-settings-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowNavigation(!showNavigation)}
className="lg:hidden h-8 w-8 p-0"
aria-label="Toggle navigation menu"
>
<Menu className="w-4 h-4" />
</Button>
<Settings className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Project Settings</h1>
<p className="text-sm text-muted-foreground">
Configure settings for {currentProject.name}
</p>
</div>
</div>
</div>
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Side Navigation */}
<ProjectSettingsNavigation
activeSection={activeView}
onNavigate={navigateTo}
isOpen={showNavigation}
onClose={() => setShowNavigation(false)}
/>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</div>
);
}

View File

@@ -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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Palette className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Theme</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize the theme for this project.
</p>
</div>
<div className="p-6 space-y-6">
{/* Use Global Theme Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-global-theme"
checked={!hasCustomTheme}
onCheckedChange={handleUseGlobalTheme}
className="mt-1"
data-testid="use-global-theme-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-global-theme"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Palette className="w-4 h-4 text-brand-500" />
Use Global Theme
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, this project will use the global theme setting. Disable to set a
project-specific theme.
</p>
</div>
</div>
{/* Theme Selection - only show if not using global theme */}
{hasCustomTheme && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Project Theme</Label>
{/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button
onClick={() => setActiveTab('dark')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
activeTab === 'dark'
? 'bg-brand-500 text-white shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Moon className="w-3.5 h-3.5" />
Dark
</button>
<button
onClick={() => setActiveTab('light')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
activeTab === 'light'
? 'bg-brand-500 text-white shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Sun className="w-3.5 h-3.5" />
Light
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{themesToShow.map(({ value, label, Icon, testId, color }) => {
const isActive = effectiveTheme === value;
return (
<button
key={value}
onClick={() => handleThemeChange(value)}
className={cn(
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
'text-sm font-medium transition-all duration-200 ease-out',
isActive
? [
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
'border-2 border-brand-500/40',
'text-foreground',
'shadow-md shadow-brand-500/10',
]
: [
'bg-accent/30 hover:bg-accent/50',
'border border-border/50 hover:border-border',
'text-muted-foreground hover:text-foreground',
'hover:shadow-sm',
],
'hover:scale-[1.02] active:scale-[0.98]'
)}
data-testid={`project-${testId}`}
>
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
)}
{/* Info when using global theme */}
{!hasCustomTheme && (
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
This project is using the global theme:{' '}
<span className="font-medium text-foreground">{globalTheme}</span>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -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<InitScriptResponse>(
`/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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<GitBranch className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Worktree Preferences
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure worktree behavior for this project.
</p>
</div>
<div className="p-6 space-y-5">
{/* Enable Git Worktree Isolation Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="project-use-worktrees"
checked={effectiveUseWorktrees}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="project-use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature in this project. When disabled, agents
work directly in the main project directory.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Show Init Script Indicator Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
{/* Auto-dismiss Init Script Indicator Toggle */}
{showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && <span className="text-amber-500 font-medium">(unsaved changes)</span>}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation'; import { NAV_ITEMS } from './settings-view/config/navigation';
import { SettingsHeader } from './settings-view/components/settings-header'; import { SettingsHeader } from './settings-view/components/settings-header';
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog'; 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 { SettingsNavigation } from './settings-view/components/settings-navigation';
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
import { ModelDefaultsSection } from './settings-view/model-defaults'; 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 { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { WorktreesSection } from './settings-view/worktrees'; import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account'; import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security'; import { SecuritySection } from './settings-view/security';
import { DeveloperSection } from './settings-view/developer/developer-section'; 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 { PromptCustomizationSection } from './settings-view/prompts';
import { EventHooksSection } from './settings-view/event-hooks'; import { EventHooksSection } from './settings-view/event-hooks';
import { ImportExportDialog } from './settings-view/components/import-export-dialog'; import { ImportExportDialog } from './settings-view/components/import-export-dialog';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint) // Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024; const LG_BREAKPOINT = 1024;
@@ -40,7 +37,6 @@ export function SettingsView() {
const { const {
theme, theme,
setTheme, setTheme,
setProjectTheme,
defaultSkipTests, defaultSkipTests,
setDefaultSkipTests, setDefaultSkipTests,
enableDependencyBlocking, enableDependencyBlocking,
@@ -54,7 +50,6 @@ export function SettingsView() {
muteDoneSound, muteDoneSound,
setMuteDoneSound, setMuteDoneSound,
currentProject, currentProject,
moveProjectToTrash,
defaultPlanningMode, defaultPlanningMode,
setDefaultPlanningMode, setDefaultPlanningMode,
defaultRequirePlanApproval, defaultRequirePlanApproval,
@@ -69,34 +64,8 @@ export function SettingsView() {
setSkipSandboxWarning, setSkipSandboxWarning,
} = useAppStore(); } = useAppStore();
// Convert electron Project to settings-view Project type // Global theme (project-specific themes are managed in Project Settings)
const convertProject = (project: ElectronProject | null): SettingsProject | null => { const globalTheme = theme as Theme;
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);
}
};
// Get initial view from URL search params // Get initial view from URL search params
const { view: initialView } = useSearch({ from: '/settings' }); const { view: initialView } = useSearch({ from: '/settings' });
@@ -113,7 +82,6 @@ export function SettingsView() {
} }
}; };
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
const [showImportExportDialog, setShowImportExportDialog] = useState(false); const [showImportExportDialog, setShowImportExportDialog] = useState(false);
@@ -172,9 +140,8 @@ export function SettingsView() {
case 'appearance': case 'appearance':
return ( return (
<AppearanceSection <AppearanceSection
effectiveTheme={effectiveTheme as any} effectiveTheme={globalTheme}
currentProject={settingsProject as any} onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
onThemeChange={(theme) => handleSetTheme(theme as any)}
/> />
); );
case 'terminal': case 'terminal':
@@ -223,13 +190,6 @@ export function SettingsView() {
); );
case 'developer': case 'developer':
return <DeveloperSection />; return <DeveloperSection />;
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default: default:
return <ApiKeysSection />; return <ApiKeysSection />;
} }
@@ -265,14 +225,6 @@ export function SettingsView() {
{/* Keyboard Map Dialog */} {/* Keyboard Map Dialog */}
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} /> <KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Import/Export Settings Dialog */} {/* Import/Export Settings Dialog */}
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} /> <ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
</div> </div>

View File

@@ -1,118 +1,20 @@
import { useState, useRef, useEffect } from 'react'; import { useState } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Palette, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options'; import { darkThemes, lightThemes } from '@/config/theme-options';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import type { Theme } from '../shared/types';
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';
interface AppearanceSectionProps { interface AppearanceSectionProps {
effectiveTheme: Theme; effectiveTheme: Theme;
currentProject: Project | null;
onThemeChange: (theme: Theme) => void; onThemeChange: (theme: Theme) => void;
} }
export function AppearanceSection({ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
effectiveTheme,
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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; 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<HTMLInputElement>) => {
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 ( return (
<div <div
className={cn( className={cn(
@@ -134,94 +36,10 @@ export function AppearanceSection({
</p> </p>
</div> </div>
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Project Details Section */}
{currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
)}
{/* Theme Section */} {/* Theme Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-foreground font-medium"> <Label className="text-foreground font-medium">Theme</Label>
Theme{' '}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
</span>
</Label>
{/* Dark/Light Tabs */} {/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30"> <div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button <button

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import type { NavigationItem, NavigationGroup } from '../config/navigation'; import type { NavigationItem, NavigationGroup } from '../config/navigation';
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation'; import { GLOBAL_NAV_GROUPS } from '../config/navigation';
import type { SettingsViewId } from '../hooks/use-settings-view'; import type { SettingsViewId } from '../hooks/use-settings-view';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types'; import type { ModelProvider } from '@automaker/types';
@@ -272,31 +272,6 @@ export function SettingsNavigation({
</div> </div>
</div> </div>
))} ))}
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</div> </div>
</nav> </nav>
</> </>

View File

@@ -8,13 +8,11 @@ import {
Settings2, Settings2,
Volume2, Volume2,
FlaskConical, FlaskConical,
Trash2,
Workflow, Workflow,
Plug, Plug,
MessageSquareText, MessageSquareText,
User, User,
Shield, Shield,
Cpu,
GitBranch, GitBranch,
Code2, Code2,
Webhook, Webhook,
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
// Flat list of all global nav items for backwards compatibility // Flat list of all global nav items for backwards compatibility
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items); 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 // Legacy export for backwards compatibility
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS]; export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;

View File

@@ -1,172 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { GitBranch } from 'lucide-react';
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 { 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 { interface WorktreesSectionProps {
useWorktrees: boolean; useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void; onUseWorktreesChange: (value: boolean) => void;
} }
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { 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<InitScriptResponse>(
`/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 ( return (
<div <div
className={cn( className={cn(
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2> <h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12"> <p className="text-sm text-muted-foreground/80 ml-12">
Configure git worktree isolation and initialization scripts. Configure git worktree isolation for feature development.
</p> </p>
</div> </div>
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
</div> </div>
</div> </div>
{/* Show Init Script Indicator Toggle */} {/* Info about project-specific settings */}
{currentProject && ( <div className="rounded-xl border border-border/30 bg-muted/30 p-4">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4"> <p className="text-xs text-muted-foreground">
<Checkbox Project-specific worktree preferences (init script, delete branch behavior) can be
id="show-init-script-indicator" configured in each project's settings via the sidebar.
checked={showIndicator}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
)}
{/* Auto-dismiss Init Script Indicator Toggle */}
{currentProject && showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
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"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p> </p>
{currentProject ? (
<>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</>
) : (
<div className="text-sm text-muted-foreground/60 py-4 text-center">
Select a project to configure the init script.
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2175,6 +2175,9 @@ export class HttpApiClient implements ElectronAPI {
hideScrollbar: boolean; hideScrollbar: boolean;
}; };
worktreePanelVisible?: boolean; worktreePanelVisible?: boolean;
showInitScriptIndicator?: boolean;
defaultDeleteBranchWithWorktree?: boolean;
autoDismissInitScriptIndicator?: boolean;
lastSelectedSessionId?: string; lastSelectedSessionId?: string;
}; };
error?: string; error?: string;

View File

@@ -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,
});

View File

@@ -231,6 +231,7 @@ export interface KeyboardShortcuts {
context: string; context: string;
memory: string; memory: string;
settings: string; settings: string;
projectSettings: string;
terminal: string; terminal: string;
ideation: string; ideation: string;
notifications: string; notifications: string;
@@ -267,6 +268,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
context: 'C', context: 'C',
memory: 'Y', memory: 'Y',
settings: 'S', settings: 'S',
projectSettings: 'Shift+S',
terminal: 'T', terminal: 'T',
ideation: 'I', ideation: 'I',
notifications: 'X', notifications: 'X',
@@ -732,6 +734,10 @@ export interface AppState {
// Whether to auto-dismiss the indicator after completion (default: true) // Whether to auto-dismiss the indicator after completion (default: true)
autoDismissInitScriptIndicatorByProject: Record<string, boolean>; autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
// Use Worktrees Override (per-project, keyed by project path)
// undefined = use global setting, true/false = project-specific override
useWorktreesByProject: Record<string, boolean | undefined>;
// UI State (previously in localStorage, now synced via API) // UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */ /** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean; worktreePanelCollapsed: boolean;
@@ -1185,6 +1191,11 @@ export interface AppActions {
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void; setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean; 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) // UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void; setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void; setLastProjectDir: (dir: string) => void;
@@ -1345,6 +1356,7 @@ const initialState: AppState = {
showInitScriptIndicatorByProject: {}, showInitScriptIndicatorByProject: {},
defaultDeleteBranchByProject: {}, defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {}, autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
// UI State (previously in localStorage, now synced via API) // UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false, worktreePanelCollapsed: false,
lastProjectDir: '', lastProjectDir: '',
@@ -3528,6 +3540,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true; 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) // UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }),

View File

@@ -299,6 +299,8 @@ export interface KeyboardShortcuts {
context: string; context: string;
/** Open settings */ /** Open settings */
settings: string; settings: string;
/** Open project settings */
projectSettings: string;
/** Open terminal */ /** Open terminal */
terminal: string; terminal: string;
/** Open notifications */ /** Open notifications */
@@ -804,6 +806,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
spec: 'D', spec: 'D',
context: 'C', context: 'C',
settings: 'S', settings: 'S',
projectSettings: 'Shift+S',
terminal: 'T', terminal: 'T',
notifications: 'X', notifications: 'X',
toggleSidebar: '`', toggleSidebar: '`',

17
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "automaker", "name": "automaker",
"version": "0.12.0rc", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "automaker", "name": "automaker",
"version": "0.12.0rc", "version": "1.0.0",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -29,7 +29,7 @@
}, },
"apps/server": { "apps/server": {
"name": "@automaker/server", "name": "@automaker/server",
"version": "0.12.0", "version": "0.10.0",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.76", "@anthropic-ai/claude-agent-sdk": "0.1.76",
@@ -80,7 +80,7 @@
}, },
"apps/ui": { "apps/ui": {
"name": "@automaker/ui", "name": "@automaker/ui",
"version": "0.12.0", "version": "0.10.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
@@ -11607,7 +11607,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11629,7 +11628,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11673,7 +11671,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11695,7 +11692,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11717,7 +11713,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11739,7 +11734,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11761,7 +11755,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11783,7 +11776,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11805,7 +11797,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },