mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'v0.12.0rc' of github.com:AutoMaker-Org/automaker into v0.12.0rc
This commit is contained in:
@@ -13,7 +13,7 @@ export { specOutputSchema } from '@automaker/types';
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
function escapeXml(str: string | undefined | null): string {
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
465
apps/server/src/lib/xml-extractor.ts
Normal file
465
apps/server/src/lib/xml-extractor.ts
Normal 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML entities back to regular characters
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content of a specific XML section
|
||||
*
|
||||
* @param xmlContent - The full XML content
|
||||
* @param tagName - The tag name to extract (e.g., 'implemented_features')
|
||||
* @param options - Optional extraction options
|
||||
* @returns The content between the tags, or null if not found
|
||||
*/
|
||||
export function extractXmlSection(
|
||||
xmlContent: string,
|
||||
tagName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string | null {
|
||||
const log = options.logger || logger;
|
||||
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
||||
const match = xmlContent.match(regex);
|
||||
|
||||
if (match) {
|
||||
log.debug(`Extracted <${tagName}> section`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
log.debug(`Section <${tagName}> not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all values from repeated XML elements
|
||||
*
|
||||
* @param xmlContent - The XML content to search
|
||||
* @param tagName - The tag name to extract values from
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of extracted values (unescaped)
|
||||
*/
|
||||
export function extractXmlElements(
|
||||
xmlContent: string,
|
||||
tagName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const log = options.logger || logger;
|
||||
const values: string[] = [];
|
||||
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g');
|
||||
const matches = xmlContent.matchAll(regex);
|
||||
|
||||
for (const match of matches) {
|
||||
values.push(unescapeXml(match[1].trim()));
|
||||
}
|
||||
|
||||
log.debug(`Extracted ${values.length} <${tagName}> elements`);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of implemented features with name, description, and optional file_locations
|
||||
*/
|
||||
export function extractImplementedFeatures(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): ImplementedFeature[] {
|
||||
const log = options.logger || logger;
|
||||
const features: ImplementedFeature[] = [];
|
||||
|
||||
// Match <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 }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
@@ -24,6 +24,19 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is provided
|
||||
if (feature.title && feature.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${feature.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
|
||||
// Emit feature_created event for hooks
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('features/update');
|
||||
|
||||
// Statuses that should trigger syncing to app_spec.txt
|
||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is being updated
|
||||
if (updates.title && updates.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(
|
||||
projectPath,
|
||||
updates.title,
|
||||
featureId // Exclude the current feature from duplicate check
|
||||
);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${updates.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
const newStatus = updates.status as FeatureStatus | undefined;
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
projectPath,
|
||||
featureId,
|
||||
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||
try {
|
||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||
if (synced) {
|
||||
logger.info(
|
||||
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
|
||||
);
|
||||
}
|
||||
} catch (syncError) {
|
||||
// Log the sync error but don't fail the update operation
|
||||
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
logError(error, 'Update feature failed');
|
||||
|
||||
@@ -2125,6 +2125,16 @@ Format your response as a structured markdown document.`;
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync completed/verified features to app_spec.txt
|
||||
if (status === 'verified' || status === 'completed') {
|
||||
try {
|
||||
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
||||
} catch (syncError) {
|
||||
// Log but don't fail the status update if sync fails
|
||||
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Feature file may not exist
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
getFeaturesDir,
|
||||
getFeatureDir,
|
||||
getFeatureImagesDir,
|
||||
getAppSpecPath,
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('FeatureLoader');
|
||||
|
||||
@@ -236,6 +238,69 @@ export class FeatureLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a title for comparison (case-insensitive, trimmed)
|
||||
*/
|
||||
private normalizeTitle(title: string): string {
|
||||
return title.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a feature by its title (case-insensitive match)
|
||||
* @param projectPath - Path to the project
|
||||
* @param title - Title to search for
|
||||
* @returns The matching feature or null if not found
|
||||
*/
|
||||
async findByTitle(projectPath: string, title: string): Promise<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
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1027
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
1027
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -518,7 +518,11 @@ Resets in 2h
|
||||
|
||||
const promise = ptyService.fetchUsageData();
|
||||
|
||||
dataCallback!('authentication_error');
|
||||
// Send data containing the authentication error pattern the service looks for
|
||||
dataCallback!('"type":"authentication_error"');
|
||||
|
||||
// Trigger the exit handler which checks for auth errors
|
||||
exitCallback!({ exitCode: 1 });
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||
|
||||
@@ -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('<special>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('&'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add empty file_locations array', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature Without Locations',
|
||||
category: 'ui',
|
||||
description: 'No file locations',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
|
||||
|
||||
// File locations should not be included when array is empty
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenContent = writeCall[1] as string;
|
||||
|
||||
// Count occurrences of file_locations - should only have the one from Existing Feature if any
|
||||
// The new feature should not add file_locations
|
||||
expect(writtenContent).toContain('Feature Without Locations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ export function SidebarFooter({
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Settings' : undefined}
|
||||
title={!sidebarOpen ? 'Global Settings' : undefined}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings
|
||||
@@ -168,7 +168,7 @@ export function SidebarFooter({
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
Global Settings
|
||||
</span>
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
@@ -194,7 +194,7 @@ export function SidebarFooter({
|
||||
'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">
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
|
||||
@@ -41,7 +41,13 @@ export function SidebarNavigation({
|
||||
</span>
|
||||
</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 */}
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Brain,
|
||||
Network,
|
||||
Bell,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -33,6 +34,7 @@ interface UseNavigationProps {
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
projectSettings: string;
|
||||
ideation: string;
|
||||
githubIssues: string;
|
||||
githubPrs: string;
|
||||
@@ -218,6 +220,19 @@ export function useNavigation({
|
||||
],
|
||||
});
|
||||
|
||||
// Add Project Settings as a standalone section (no label for visual separation)
|
||||
sections.push({
|
||||
label: '',
|
||||
items: [
|
||||
{
|
||||
id: 'project-settings',
|
||||
label: 'Project Settings',
|
||||
icon: Settings,
|
||||
shortcut: shortcuts.projectSettings,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}, [
|
||||
shortcuts,
|
||||
@@ -277,11 +292,11 @@ export function useNavigation({
|
||||
});
|
||||
});
|
||||
|
||||
// Add settings shortcut
|
||||
// Add global settings shortcut
|
||||
shortcutsList.push({
|
||||
key: shortcuts.settings,
|
||||
action: () => navigate({ to: '/settings' }),
|
||||
description: 'Navigate to Settings',
|
||||
description: 'Navigate to Global Settings',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--accent)',
|
||||
opacity: '0.3',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0.25rem',
|
||||
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
|
||||
}: ShellSyntaxEditorProps) {
|
||||
return (
|
||||
<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 }}
|
||||
data-testid={testId}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
|
||||
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
||||
@@ -16,7 +15,6 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { WorktreesSection } from './settings-view/worktrees';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { AccountSection } from './settings-view/account';
|
||||
import { SecuritySection } from './settings-view/security';
|
||||
import { DeveloperSection } from './settings-view/developer/developer-section';
|
||||
@@ -30,8 +28,7 @@ import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
import { EventHooksSection } from './settings-view/event-hooks';
|
||||
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
import type { Theme } from './settings-view/shared/types';
|
||||
|
||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||
const LG_BREAKPOINT = 1024;
|
||||
@@ -40,7 +37,6 @@ export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
@@ -54,7 +50,6 @@ export function SettingsView() {
|
||||
muteDoneSound,
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
defaultPlanningMode,
|
||||
setDefaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
@@ -69,34 +64,8 @@ export function SettingsView() {
|
||||
setSkipSandboxWarning,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
icon: project.icon,
|
||||
customIconPath: project.customIconPath,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
||||
|
||||
// Handler to set theme - always updates global theme (user's preference),
|
||||
// and also sets per-project theme if a project is selected
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
// Always update global theme so user's preference persists across all projects
|
||||
setTheme(newTheme);
|
||||
// Also set per-project theme if a project is selected
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
}
|
||||
};
|
||||
// Global theme (project-specific themes are managed in Project Settings)
|
||||
const globalTheme = theme as Theme;
|
||||
|
||||
// Get initial view from URL search params
|
||||
const { view: initialView } = useSearch({ from: '/settings' });
|
||||
@@ -113,7 +82,6 @@ export function SettingsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||
|
||||
@@ -172,9 +140,8 @@ export function SettingsView() {
|
||||
case 'appearance':
|
||||
return (
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme as any}
|
||||
currentProject={settingsProject as any}
|
||||
onThemeChange={(theme) => handleSetTheme(theme as any)}
|
||||
effectiveTheme={globalTheme}
|
||||
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
|
||||
/>
|
||||
);
|
||||
case 'terminal':
|
||||
@@ -223,13 +190,6 @@ export function SettingsView() {
|
||||
);
|
||||
case 'developer':
|
||||
return <DeveloperSection />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <ApiKeysSection />;
|
||||
}
|
||||
@@ -265,14 +225,6 @@ export function SettingsView() {
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* Import/Export Settings Dialog */}
|
||||
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,20 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
|
||||
import { Palette, Moon, Sun } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Theme, Project } from '../shared/types';
|
||||
import type { Theme } from '../shared/types';
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
currentProject: Project | null;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export function AppearanceSection({
|
||||
effectiveTheme,
|
||||
currentProject,
|
||||
onThemeChange,
|
||||
}: AppearanceSectionProps) {
|
||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
||||
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
|
||||
const [projectIcon, setProjectIconLocal] = useState<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;
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -134,94 +36,10 @@ export function AppearanceSection({
|
||||
</p>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">
|
||||
Theme{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="text-foreground font-medium">Theme</Label>
|
||||
{/* Dark/Light Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/lib/electron';
|
||||
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 { useAppStore } from '@/store/app-store';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
@@ -272,31 +272,6 @@ export function SettingsNavigation({
|
||||
</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>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
Settings2,
|
||||
Volume2,
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Workflow,
|
||||
Plug,
|
||||
MessageSquareText,
|
||||
User,
|
||||
Shield,
|
||||
Cpu,
|
||||
GitBranch,
|
||||
Code2,
|
||||
Webhook,
|
||||
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
// Flat list of all global nav items for backwards compatibility
|
||||
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
|
||||
|
||||
// Project-specific settings - only visible when a project is selected
|
||||
export const PROJECT_NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
|
||||
];
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS];
|
||||
export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;
|
||||
|
||||
@@ -1,172 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||
import {
|
||||
GitBranch,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
PanelBottomClose,
|
||||
} from 'lucide-react';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
|
||||
interface WorktreesSectionProps {
|
||||
useWorktrees: boolean;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface InitScriptResponse {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
||||
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
||||
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [scriptExists, setScriptExists] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Get the current show indicator setting
|
||||
const showIndicator = currentProject?.path
|
||||
? getShowInitScriptIndicator(currentProject.path)
|
||||
: true;
|
||||
|
||||
// Get the default delete branch setting
|
||||
const defaultDeleteBranch = currentProject?.path
|
||||
? getDefaultDeleteBranch(currentProject.path)
|
||||
: false;
|
||||
|
||||
// Get the auto-dismiss setting
|
||||
const autoDismiss = currentProject?.path
|
||||
? getAutoDismissInitScriptIndicator(currentProject.path)
|
||||
: true;
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = scriptContent !== originalContent;
|
||||
|
||||
// Load init script content when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) {
|
||||
setScriptContent('');
|
||||
setOriginalContent('');
|
||||
setScriptExists(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitScript = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiGet<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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Init Script Indicator 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 mt-4">
|
||||
<Checkbox
|
||||
id="show-init-script-indicator"
|
||||
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.
|
||||
{/* Info about project-specific settings */}
|
||||
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Project-specific worktree preferences (init script, delete branch behavior) can be
|
||||
configured in each project's settings via the sidebar.
|
||||
</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>
|
||||
|
||||
@@ -2175,6 +2175,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
hideScrollbar: boolean;
|
||||
};
|
||||
worktreePanelVisible?: boolean;
|
||||
showInitScriptIndicator?: boolean;
|
||||
defaultDeleteBranchWithWorktree?: boolean;
|
||||
autoDismissInitScriptIndicator?: boolean;
|
||||
lastSelectedSessionId?: string;
|
||||
};
|
||||
error?: string;
|
||||
|
||||
6
apps/ui/src/routes/project-settings.tsx
Normal file
6
apps/ui/src/routes/project-settings.tsx
Normal 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,
|
||||
});
|
||||
@@ -231,6 +231,7 @@ export interface KeyboardShortcuts {
|
||||
context: string;
|
||||
memory: string;
|
||||
settings: string;
|
||||
projectSettings: string;
|
||||
terminal: string;
|
||||
ideation: string;
|
||||
notifications: string;
|
||||
@@ -267,6 +268,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||
context: 'C',
|
||||
memory: 'Y',
|
||||
settings: 'S',
|
||||
projectSettings: 'Shift+S',
|
||||
terminal: 'T',
|
||||
ideation: 'I',
|
||||
notifications: 'X',
|
||||
@@ -732,6 +734,10 @@ export interface AppState {
|
||||
// Whether to auto-dismiss the indicator after completion (default: true)
|
||||
autoDismissInitScriptIndicatorByProject: Record<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)
|
||||
/** Whether worktree panel is collapsed in board view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
@@ -1185,6 +1191,11 @@ export interface AppActions {
|
||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||
|
||||
// Use Worktrees Override actions (per-project)
|
||||
setProjectUseWorktrees: (projectPath: string, useWorktrees: boolean | null) => void; // null = use global
|
||||
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
|
||||
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||
setLastProjectDir: (dir: string) => void;
|
||||
@@ -1345,6 +1356,7 @@ const initialState: AppState = {
|
||||
showInitScriptIndicatorByProject: {},
|
||||
defaultDeleteBranchByProject: {},
|
||||
autoDismissInitScriptIndicatorByProject: {},
|
||||
useWorktreesByProject: {},
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
worktreePanelCollapsed: false,
|
||||
lastProjectDir: '',
|
||||
@@ -3528,6 +3540,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
||||
},
|
||||
|
||||
// Use Worktrees Override actions (per-project)
|
||||
setProjectUseWorktrees: (projectPath, useWorktrees) => {
|
||||
const newValue = useWorktrees === null ? undefined : useWorktrees;
|
||||
set({
|
||||
useWorktreesByProject: {
|
||||
...get().useWorktreesByProject,
|
||||
[projectPath]: newValue,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getProjectUseWorktrees: (projectPath) => {
|
||||
// Returns undefined if using global setting, true/false if project-specific
|
||||
return get().useWorktreesByProject[projectPath];
|
||||
},
|
||||
|
||||
getEffectiveUseWorktrees: (projectPath) => {
|
||||
// Returns the actual value to use (project override or global fallback)
|
||||
const projectSetting = get().useWorktreesByProject[projectPath];
|
||||
if (projectSetting !== undefined) {
|
||||
return projectSetting;
|
||||
}
|
||||
return get().useWorktrees;
|
||||
},
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||
|
||||
Reference in New Issue
Block a user