mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +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
|
* Escape special XML characters
|
||||||
* Handles undefined/null values by converting them to empty strings
|
* Handles undefined/null values by converting them to empty strings
|
||||||
*/
|
*/
|
||||||
function escapeXml(str: string | undefined | null): string {
|
export function escapeXml(str: string | undefined | null): string {
|
||||||
if (str == null) {
|
if (str == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicate title if title is provided
|
||||||
|
if (feature.title && feature.title.trim()) {
|
||||||
|
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||||
|
if (duplicate) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: `A feature with title "${feature.title}" already exists`,
|
||||||
|
duplicateFeatureId: duplicate.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await featureLoader.create(projectPath, feature);
|
const created = await featureLoader.create(projectPath, feature);
|
||||||
|
|
||||||
// Emit feature_created event for hooks
|
// Emit feature_created event for hooks
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
import type { Feature } from '@automaker/types';
|
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('features/update');
|
||||||
|
|
||||||
|
// Statuses that should trigger syncing to app_spec.txt
|
||||||
|
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||||
|
|
||||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicate title if title is being updated
|
||||||
|
if (updates.title && updates.title.trim()) {
|
||||||
|
const duplicate = await featureLoader.findDuplicateTitle(
|
||||||
|
projectPath,
|
||||||
|
updates.title,
|
||||||
|
featureId // Exclude the current feature from duplicate check
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: `A feature with title "${updates.title}" already exists`,
|
||||||
|
duplicateFeatureId: duplicate.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current feature to detect status changes
|
||||||
|
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||||
|
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||||
|
const newStatus = updates.status as FeatureStatus | undefined;
|
||||||
|
|
||||||
const updated = await featureLoader.update(
|
const updated = await featureLoader.update(
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
enhancementMode,
|
enhancementMode,
|
||||||
preEnhancementDescription
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||||
|
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||||
|
try {
|
||||||
|
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||||
|
if (synced) {
|
||||||
|
logger.info(
|
||||||
|
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (syncError) {
|
||||||
|
// Log the sync error but don't fail the update operation
|
||||||
|
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, feature: updated });
|
res.json({ success: true, feature: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Update feature failed');
|
logError(error, 'Update feature failed');
|
||||||
|
|||||||
@@ -2125,6 +2125,16 @@ Format your response as a structured markdown document.`;
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync completed/verified features to app_spec.txt
|
||||||
|
if (status === 'verified' || status === 'completed') {
|
||||||
|
try {
|
||||||
|
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
||||||
|
} catch (syncError) {
|
||||||
|
// Log but don't fail the status update if sync fails
|
||||||
|
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Feature file may not exist
|
// Feature file may not exist
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
getFeaturesDir,
|
getFeaturesDir,
|
||||||
getFeatureDir,
|
getFeatureDir,
|
||||||
getFeatureImagesDir,
|
getFeatureImagesDir,
|
||||||
|
getAppSpecPath,
|
||||||
ensureAutomakerDir,
|
ensureAutomakerDir,
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
|
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
|
||||||
|
|
||||||
const logger = createLogger('FeatureLoader');
|
const logger = createLogger('FeatureLoader');
|
||||||
|
|
||||||
@@ -236,6 +238,69 @@ export class FeatureLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a title for comparison (case-insensitive, trimmed)
|
||||||
|
*/
|
||||||
|
private normalizeTitle(title: string): string {
|
||||||
|
return title.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a feature by its title (case-insensitive match)
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param title - Title to search for
|
||||||
|
* @returns The matching feature or null if not found
|
||||||
|
*/
|
||||||
|
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTitle = this.normalizeTitle(title);
|
||||||
|
const features = await this.getAll(projectPath);
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a title already exists on another feature (for duplicate detection)
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param title - Title to check
|
||||||
|
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
|
||||||
|
* @returns The duplicate feature if found, null otherwise
|
||||||
|
*/
|
||||||
|
async findDuplicateTitle(
|
||||||
|
projectPath: string,
|
||||||
|
title: string,
|
||||||
|
excludeFeatureId?: string
|
||||||
|
): Promise<Feature | null> {
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTitle = this.normalizeTitle(title);
|
||||||
|
const features = await this.getAll(projectPath);
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
// Skip the feature being updated (if provided)
|
||||||
|
if (excludeFeatureId && feature.id === excludeFeatureId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single feature by ID
|
* Get a single feature by ID
|
||||||
*/
|
*/
|
||||||
@@ -460,4 +525,64 @@ export class FeatureLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a completed feature to the app_spec.txt implemented_features section
|
||||||
|
*
|
||||||
|
* When a feature is completed, this method adds it to the implemented_features
|
||||||
|
* section of the project's app_spec.txt file. This keeps the spec in sync
|
||||||
|
* with the actual state of the codebase.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param feature - The feature to sync (must have title or description)
|
||||||
|
* @param fileLocations - Optional array of file paths where the feature was implemented
|
||||||
|
* @returns True if the spec was updated, false if no spec exists or feature was skipped
|
||||||
|
*/
|
||||||
|
async syncFeatureToAppSpec(
|
||||||
|
projectPath: string,
|
||||||
|
feature: Feature,
|
||||||
|
fileLocations?: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const appSpecPath = getAppSpecPath(projectPath);
|
||||||
|
|
||||||
|
// Read the current app_spec.txt
|
||||||
|
let specContent: string;
|
||||||
|
try {
|
||||||
|
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the implemented feature entry
|
||||||
|
const featureName = feature.title || `Feature: ${feature.id}`;
|
||||||
|
const implementedFeature: ImplementedFeature = {
|
||||||
|
name: featureName,
|
||||||
|
description: feature.description,
|
||||||
|
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the feature to the implemented_features section
|
||||||
|
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
|
||||||
|
|
||||||
|
// Check if the content actually changed (feature might already exist)
|
||||||
|
if (updatedSpecContent === specContent) {
|
||||||
|
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the updated spec back to the file
|
||||||
|
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
|
||||||
|
|
||||||
|
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
const promise = ptyService.fetchUsageData();
|
||||||
|
|
||||||
dataCallback!('authentication_error');
|
// Send data containing the authentication error pattern the service looks for
|
||||||
|
dataCallback!('"type":"authentication_error"');
|
||||||
|
|
||||||
|
// Trigger the exit handler which checks for auth errors
|
||||||
|
exitCallback!({ exitCode: 1 });
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow(
|
await expect(promise).rejects.toThrow(
|
||||||
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
|
||||||
|
|||||||
@@ -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',
|
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||||
'hover:scale-[1.02] active:scale-[0.97]'
|
'hover:scale-[1.02] active:scale-[0.97]'
|
||||||
)}
|
)}
|
||||||
title={!sidebarOpen ? 'Settings' : undefined}
|
title={!sidebarOpen ? 'Global Settings' : undefined}
|
||||||
data-testid="settings-button"
|
data-testid="settings-button"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
@@ -168,7 +168,7 @@ export function SidebarFooter({
|
|||||||
sidebarOpen ? 'block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Settings
|
Global Settings
|
||||||
</span>
|
</span>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
@@ -194,7 +194,7 @@ export function SidebarFooter({
|
|||||||
'translate-x-1 group-hover:translate-x-0'
|
'translate-x-1 group-hover:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Settings
|
Global Settings
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
{formatShortcut(shortcuts.settings, true)}
|
{formatShortcut(shortcuts.settings, true)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ export function SidebarNavigation({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
|
{/* Separator for sections without label (visual separation) */}
|
||||||
|
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
||||||
|
<div className="h-px bg-border/40 mx-3 mb-4"></div>
|
||||||
|
)}
|
||||||
|
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
||||||
|
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Nav Items */}
|
{/* Nav Items */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Network,
|
Network,
|
||||||
Bell,
|
Bell,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavSection, NavItem } from '../types';
|
import type { NavSection, NavItem } from '../types';
|
||||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
@@ -33,6 +34,7 @@ interface UseNavigationProps {
|
|||||||
agent: string;
|
agent: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
projectSettings: string;
|
||||||
ideation: string;
|
ideation: string;
|
||||||
githubIssues: string;
|
githubIssues: string;
|
||||||
githubPrs: string;
|
githubPrs: string;
|
||||||
@@ -218,6 +220,19 @@ export function useNavigation({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add Project Settings as a standalone section (no label for visual separation)
|
||||||
|
sections.push({
|
||||||
|
label: '',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'project-settings',
|
||||||
|
label: 'Project Settings',
|
||||||
|
icon: Settings,
|
||||||
|
shortcut: shortcuts.projectSettings,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}, [
|
}, [
|
||||||
shortcuts,
|
shortcuts,
|
||||||
@@ -277,11 +292,11 @@ export function useNavigation({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add settings shortcut
|
// Add global settings shortcut
|
||||||
shortcutsList.push({
|
shortcutsList.push({
|
||||||
key: shortcuts.settings,
|
key: shortcuts.settings,
|
||||||
action: () => navigate({ to: '/settings' }),
|
action: () => navigate({ to: '/settings' }),
|
||||||
description: 'Navigate to Settings',
|
description: 'Navigate to Global Settings',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
|
|||||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||||
},
|
},
|
||||||
'.cm-activeLine': {
|
'.cm-activeLine': {
|
||||||
backgroundColor: 'var(--accent)',
|
backgroundColor: 'transparent',
|
||||||
opacity: '0.3',
|
|
||||||
},
|
},
|
||||||
'.cm-line': {
|
'.cm-line': {
|
||||||
padding: '0 0.25rem',
|
padding: '0 0.25rem',
|
||||||
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
|
|||||||
}: ShellSyntaxEditorProps) {
|
}: ShellSyntaxEditorProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
|
className={cn('w-full rounded-lg border border-border bg-background', className)}
|
||||||
style={{ minHeight }}
|
style={{ minHeight }}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { NAV_ITEMS } from './settings-view/config/navigation';
|
||||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||||
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
|
|
||||||
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
||||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||||
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
||||||
@@ -16,7 +15,6 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
|||||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||||
import { WorktreesSection } from './settings-view/worktrees';
|
import { WorktreesSection } from './settings-view/worktrees';
|
||||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
|
||||||
import { AccountSection } from './settings-view/account';
|
import { AccountSection } from './settings-view/account';
|
||||||
import { SecuritySection } from './settings-view/security';
|
import { SecuritySection } from './settings-view/security';
|
||||||
import { DeveloperSection } from './settings-view/developer/developer-section';
|
import { DeveloperSection } from './settings-view/developer/developer-section';
|
||||||
@@ -30,8 +28,7 @@ import { MCPServersSection } from './settings-view/mcp-servers';
|
|||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
import { EventHooksSection } from './settings-view/event-hooks';
|
import { EventHooksSection } from './settings-view/event-hooks';
|
||||||
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Theme } from './settings-view/shared/types';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
|
||||||
|
|
||||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
const LG_BREAKPOINT = 1024;
|
const LG_BREAKPOINT = 1024;
|
||||||
@@ -40,7 +37,6 @@ export function SettingsView() {
|
|||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
setProjectTheme,
|
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
setDefaultSkipTests,
|
setDefaultSkipTests,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
@@ -54,7 +50,6 @@ export function SettingsView() {
|
|||||||
muteDoneSound,
|
muteDoneSound,
|
||||||
setMuteDoneSound,
|
setMuteDoneSound,
|
||||||
currentProject,
|
currentProject,
|
||||||
moveProjectToTrash,
|
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
setDefaultPlanningMode,
|
setDefaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
@@ -69,34 +64,8 @@ export function SettingsView() {
|
|||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Convert electron Project to settings-view Project type
|
// Global theme (project-specific themes are managed in Project Settings)
|
||||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
const globalTheme = theme as Theme;
|
||||||
if (!project) return null;
|
|
||||||
return {
|
|
||||||
id: project.id,
|
|
||||||
name: project.name,
|
|
||||||
path: project.path,
|
|
||||||
theme: project.theme as Theme | undefined,
|
|
||||||
icon: project.icon,
|
|
||||||
customIconPath: project.customIconPath,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const settingsProject = convertProject(currentProject);
|
|
||||||
|
|
||||||
// Compute the effective theme for the current project
|
|
||||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
|
||||||
|
|
||||||
// Handler to set theme - always updates global theme (user's preference),
|
|
||||||
// and also sets per-project theme if a project is selected
|
|
||||||
const handleSetTheme = (newTheme: typeof theme) => {
|
|
||||||
// Always update global theme so user's preference persists across all projects
|
|
||||||
setTheme(newTheme);
|
|
||||||
// Also set per-project theme if a project is selected
|
|
||||||
if (currentProject) {
|
|
||||||
setProjectTheme(currentProject.id, newTheme);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get initial view from URL search params
|
// Get initial view from URL search params
|
||||||
const { view: initialView } = useSearch({ from: '/settings' });
|
const { view: initialView } = useSearch({ from: '/settings' });
|
||||||
@@ -113,7 +82,6 @@ export function SettingsView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||||
|
|
||||||
@@ -172,9 +140,8 @@ export function SettingsView() {
|
|||||||
case 'appearance':
|
case 'appearance':
|
||||||
return (
|
return (
|
||||||
<AppearanceSection
|
<AppearanceSection
|
||||||
effectiveTheme={effectiveTheme as any}
|
effectiveTheme={globalTheme}
|
||||||
currentProject={settingsProject as any}
|
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
|
||||||
onThemeChange={(theme) => handleSetTheme(theme as any)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
@@ -223,13 +190,6 @@ export function SettingsView() {
|
|||||||
);
|
);
|
||||||
case 'developer':
|
case 'developer':
|
||||||
return <DeveloperSection />;
|
return <DeveloperSection />;
|
||||||
case 'danger':
|
|
||||||
return (
|
|
||||||
<DangerZoneSection
|
|
||||||
project={settingsProject}
|
|
||||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return <ApiKeysSection />;
|
return <ApiKeysSection />;
|
||||||
}
|
}
|
||||||
@@ -265,14 +225,6 @@ export function SettingsView() {
|
|||||||
{/* Keyboard Map Dialog */}
|
{/* Keyboard Map Dialog */}
|
||||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
|
||||||
<DeleteProjectDialog
|
|
||||||
open={showDeleteDialog}
|
|
||||||
onOpenChange={setShowDeleteDialog}
|
|
||||||
project={currentProject}
|
|
||||||
onConfirm={moveProjectToTrash}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Import/Export Settings Dialog */}
|
{/* Import/Export Settings Dialog */}
|
||||||
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,118 +1,20 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Palette, Moon, Sun } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
|
|
||||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import type { Theme } from '../shared/types';
|
||||||
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
|
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
||||||
import type { Theme, Project } from '../shared/types';
|
|
||||||
|
|
||||||
interface AppearanceSectionProps {
|
interface AppearanceSectionProps {
|
||||||
effectiveTheme: Theme;
|
effectiveTheme: Theme;
|
||||||
currentProject: Project | null;
|
|
||||||
onThemeChange: (theme: Theme) => void;
|
onThemeChange: (theme: Theme) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceSection({
|
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
|
||||||
effectiveTheme,
|
|
||||||
currentProject,
|
|
||||||
onThemeChange,
|
|
||||||
}: AppearanceSectionProps) {
|
|
||||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
|
||||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||||
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
|
|
||||||
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
|
|
||||||
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
|
|
||||||
currentProject?.customIconPath || null
|
|
||||||
);
|
|
||||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Sync local state when currentProject changes
|
|
||||||
useEffect(() => {
|
|
||||||
setProjectNameLocal(currentProject?.name || '');
|
|
||||||
setProjectIconLocal(currentProject?.icon || null);
|
|
||||||
setCustomIconPathLocal(currentProject?.customIconPath || null);
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||||
|
|
||||||
// Auto-save when values change
|
|
||||||
const handleNameChange = (name: string) => {
|
|
||||||
setProjectNameLocal(name);
|
|
||||||
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
|
|
||||||
setProjectName(currentProject.id, name.trim());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIconChange = (icon: string | null) => {
|
|
||||||
setProjectIconLocal(icon);
|
|
||||||
if (currentProject) {
|
|
||||||
setProjectIcon(currentProject.id, icon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomIconChange = (path: string | null) => {
|
|
||||||
setCustomIconPathLocal(path);
|
|
||||||
if (currentProject) {
|
|
||||||
setProjectCustomIcon(currentProject.id, path);
|
|
||||||
// Clear Lucide icon when custom icon is set
|
|
||||||
if (path) {
|
|
||||||
setProjectIconLocal(null);
|
|
||||||
setProjectIcon(currentProject.id, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file || !currentProject) return;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
||||||
if (!validTypes.includes(file.type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (max 2MB for icons)
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploadingIcon(true);
|
|
||||||
try {
|
|
||||||
// Convert to base64
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async () => {
|
|
||||||
const base64Data = reader.result as string;
|
|
||||||
const result = await getHttpApiClient().saveImageToTemp(
|
|
||||||
base64Data,
|
|
||||||
`project-icon-${file.name}`,
|
|
||||||
file.type,
|
|
||||||
currentProject.path
|
|
||||||
);
|
|
||||||
if (result.success && result.path) {
|
|
||||||
handleCustomIconChange(result.path);
|
|
||||||
}
|
|
||||||
setIsUploadingIcon(false);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} catch {
|
|
||||||
setIsUploadingIcon(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCustomIcon = () => {
|
|
||||||
handleCustomIconChange(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -134,94 +36,10 @@ export function AppearanceSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Project Details Section */}
|
|
||||||
{currentProject && (
|
|
||||||
<div className="space-y-4 pb-6 border-b border-border/50">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="project-name-settings">Project Name</Label>
|
|
||||||
<Input
|
|
||||||
id="project-name-settings"
|
|
||||||
value={projectName}
|
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
|
||||||
placeholder="Enter project name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Project Icon</Label>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
Choose a preset icon or upload a custom image
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Custom Icon Upload */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{customIconPath ? (
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
|
|
||||||
alt="Custom project icon"
|
|
||||||
className="w-12 h-12 rounded-lg object-cover border border-border"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemoveCustomIcon}
|
|
||||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
|
|
||||||
<ImageIcon className="w-5 h-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
||||||
onChange={handleCustomIconUpload}
|
|
||||||
className="hidden"
|
|
||||||
id="custom-icon-upload"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploadingIcon}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
PNG, JPG, GIF or WebP. Max 2MB.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preset Icon Picker - only show if no custom icon */}
|
|
||||||
{!customIconPath && (
|
|
||||||
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Theme Section */}
|
{/* Theme Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-foreground font-medium">
|
<Label className="text-foreground font-medium">Theme</Label>
|
||||||
Theme{' '}
|
|
||||||
<span className="text-muted-foreground font-normal">
|
|
||||||
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
{/* Dark/Light Tabs */}
|
{/* Dark/Light Tabs */}
|
||||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
import { GLOBAL_NAV_GROUPS } from '../config/navigation';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { ModelProvider } from '@automaker/types';
|
import type { ModelProvider } from '@automaker/types';
|
||||||
@@ -272,31 +272,6 @@ export function SettingsNavigation({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Project Settings - only show when a project is selected */}
|
|
||||||
{currentProject && (
|
|
||||||
<>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="my-3 border-t border-border/50" />
|
|
||||||
|
|
||||||
{/* Project Settings Label */}
|
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
|
||||||
Project Settings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project Settings Items */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{PROJECT_NAV_ITEMS.map((item) => (
|
|
||||||
<NavButton
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
isActive={activeSection === item.id}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
Volume2,
|
Volume2,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Trash2,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
Plug,
|
Plug,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
Cpu,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Code2,
|
Code2,
|
||||||
Webhook,
|
Webhook,
|
||||||
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
|||||||
// Flat list of all global nav items for backwards compatibility
|
// Flat list of all global nav items for backwards compatibility
|
||||||
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
|
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
|
||||||
|
|
||||||
// Project-specific settings - only visible when a project is selected
|
|
||||||
export const PROJECT_NAV_ITEMS: NavigationItem[] = [
|
|
||||||
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Legacy export for backwards compatibility
|
// Legacy export for backwards compatibility
|
||||||
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS];
|
export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;
|
||||||
|
|||||||
@@ -1,172 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { GitBranch } from 'lucide-react';
|
||||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
|
||||||
import {
|
|
||||||
GitBranch,
|
|
||||||
Terminal,
|
|
||||||
FileCode,
|
|
||||||
Save,
|
|
||||||
RotateCcw,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
PanelBottomClose,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
||||||
|
|
||||||
interface WorktreesSectionProps {
|
interface WorktreesSectionProps {
|
||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
onUseWorktreesChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InitScriptResponse {
|
|
||||||
success: boolean;
|
|
||||||
exists: boolean;
|
|
||||||
content: string;
|
|
||||||
path: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
||||||
const currentProject = useAppStore((s) => s.currentProject);
|
|
||||||
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
|
||||||
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
|
||||||
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
|
||||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
|
||||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
|
||||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
|
||||||
const [scriptContent, setScriptContent] = useState('');
|
|
||||||
const [originalContent, setOriginalContent] = useState('');
|
|
||||||
const [scriptExists, setScriptExists] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
// Get the current show indicator setting
|
|
||||||
const showIndicator = currentProject?.path
|
|
||||||
? getShowInitScriptIndicator(currentProject.path)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
// Get the default delete branch setting
|
|
||||||
const defaultDeleteBranch = currentProject?.path
|
|
||||||
? getDefaultDeleteBranch(currentProject.path)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Get the auto-dismiss setting
|
|
||||||
const autoDismiss = currentProject?.path
|
|
||||||
? getAutoDismissInitScriptIndicator(currentProject.path)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
// Check if there are unsaved changes
|
|
||||||
const hasChanges = scriptContent !== originalContent;
|
|
||||||
|
|
||||||
// Load init script content when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
setScriptContent('');
|
|
||||||
setOriginalContent('');
|
|
||||||
setScriptExists(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadInitScript = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await apiGet<InitScriptResponse>(
|
|
||||||
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
const content = response.content || '';
|
|
||||||
setScriptContent(content);
|
|
||||||
setOriginalContent(content);
|
|
||||||
setScriptExists(response.exists);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load init script:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInitScript();
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Save script
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await apiPut<{ success: boolean; error?: string }>(
|
|
||||||
'/api/worktree/init-script',
|
|
||||||
{
|
|
||||||
projectPath: currentProject.path,
|
|
||||||
content: scriptContent,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
setOriginalContent(scriptContent);
|
|
||||||
setScriptExists(true);
|
|
||||||
toast.success('Init script saved');
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to save init script', {
|
|
||||||
description: response.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save init script:', error);
|
|
||||||
toast.error('Failed to save init script');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path, scriptContent]);
|
|
||||||
|
|
||||||
// Reset to original content
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
setScriptContent(originalContent);
|
|
||||||
}, [originalContent]);
|
|
||||||
|
|
||||||
// Delete script
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const response = await apiDelete<{ success: boolean; error?: string }>(
|
|
||||||
'/api/worktree/init-script',
|
|
||||||
{
|
|
||||||
body: { projectPath: currentProject.path },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
setScriptContent('');
|
|
||||||
setOriginalContent('');
|
|
||||||
setScriptExists(false);
|
|
||||||
toast.success('Init script deleted');
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to delete init script', {
|
|
||||||
description: response.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete init script:', error);
|
|
||||||
toast.error('Failed to delete init script');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Handle content change (no auto-save)
|
|
||||||
const handleContentChange = useCallback((value: string) => {
|
|
||||||
setScriptContent(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
|||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
Configure git worktree isolation and initialization scripts.
|
Configure git worktree isolation for feature development.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show Init Script Indicator Toggle */}
|
{/* Info about project-specific settings */}
|
||||||
{currentProject && (
|
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Checkbox
|
Project-specific worktree preferences (init script, delete branch behavior) can be
|
||||||
id="show-init-script-indicator"
|
configured in each project's settings via the sidebar.
|
||||||
checked={showIndicator}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
if (currentProject?.path) {
|
|
||||||
const value = checked === true;
|
|
||||||
setShowInitScriptIndicator(currentProject.path, value);
|
|
||||||
// Persist to server
|
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
await httpClient.settings.updateProject(currentProject.path, {
|
|
||||||
showInitScriptIndicator: value,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to persist showInitScriptIndicator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="show-init-script-indicator"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<PanelBottomClose className="w-4 h-4 text-brand-500" />
|
|
||||||
Show Init Script Indicator
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Display a floating panel in the bottom-right corner showing init script execution
|
|
||||||
status and output when a worktree is created.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto-dismiss Init Script Indicator Toggle */}
|
|
||||||
{currentProject && showIndicator && (
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
|
|
||||||
<Checkbox
|
|
||||||
id="auto-dismiss-indicator"
|
|
||||||
checked={autoDismiss}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
if (currentProject?.path) {
|
|
||||||
const value = checked === true;
|
|
||||||
setAutoDismissInitScriptIndicator(currentProject.path, value);
|
|
||||||
// Persist to server
|
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
await httpClient.settings.updateProject(currentProject.path, {
|
|
||||||
autoDismissInitScriptIndicator: value,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="auto-dismiss-indicator"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Auto-dismiss After Completion
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Automatically hide the indicator 5 seconds after the script completes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Default Delete Branch Toggle */}
|
|
||||||
{currentProject && (
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
|
||||||
<Checkbox
|
|
||||||
id="default-delete-branch"
|
|
||||||
checked={defaultDeleteBranch}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
if (currentProject?.path) {
|
|
||||||
const value = checked === true;
|
|
||||||
setDefaultDeleteBranch(currentProject.path, value);
|
|
||||||
// Persist to server
|
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
await httpClient.settings.updateProject(currentProject.path, {
|
|
||||||
defaultDeleteBranch: value,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to persist defaultDeleteBranch:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="default-delete-branch"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-brand-500" />
|
|
||||||
Delete Branch by Default
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
When deleting a worktree, automatically check the "Also delete the branch" option.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="border-t border-border/30" />
|
|
||||||
|
|
||||||
{/* Init Script Section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-4 h-4 text-brand-500" />
|
|
||||||
<Label className="text-foreground font-medium">Initialization Script</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
|
|
||||||
on Windows for cross-platform compatibility.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{currentProject ? (
|
|
||||||
<>
|
|
||||||
{/* File path indicator */}
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
|
||||||
<FileCode className="w-3.5 h-3.5" />
|
|
||||||
<code className="font-mono">.automaker/worktree-init.sh</code>
|
|
||||||
{hasChanges && (
|
|
||||||
<span className="text-amber-500 font-medium">(unsaved changes)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ShellSyntaxEditor
|
|
||||||
value={scriptContent}
|
|
||||||
onChange={handleContentChange}
|
|
||||||
placeholder={`# Example initialization commands
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Or use pnpm
|
|
||||||
# pnpm install
|
|
||||||
|
|
||||||
# Copy environment file
|
|
||||||
# cp .env.example .env`}
|
|
||||||
minHeight="200px"
|
|
||||||
maxHeight="500px"
|
|
||||||
data-testid="init-script-editor"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={!hasChanges || isSaving || isDeleting}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={!scriptExists || isSaving || isDeleting}
|
|
||||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasChanges || isSaving || isDeleting}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground/60 py-4 text-center">
|
|
||||||
Select a project to configure the init script.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2175,6 +2175,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
hideScrollbar: boolean;
|
hideScrollbar: boolean;
|
||||||
};
|
};
|
||||||
worktreePanelVisible?: boolean;
|
worktreePanelVisible?: boolean;
|
||||||
|
showInitScriptIndicator?: boolean;
|
||||||
|
defaultDeleteBranchWithWorktree?: boolean;
|
||||||
|
autoDismissInitScriptIndicator?: boolean;
|
||||||
lastSelectedSessionId?: string;
|
lastSelectedSessionId?: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
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;
|
context: string;
|
||||||
memory: string;
|
memory: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
projectSettings: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
ideation: string;
|
ideation: string;
|
||||||
notifications: string;
|
notifications: string;
|
||||||
@@ -267,6 +268,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
context: 'C',
|
context: 'C',
|
||||||
memory: 'Y',
|
memory: 'Y',
|
||||||
settings: 'S',
|
settings: 'S',
|
||||||
|
projectSettings: 'Shift+S',
|
||||||
terminal: 'T',
|
terminal: 'T',
|
||||||
ideation: 'I',
|
ideation: 'I',
|
||||||
notifications: 'X',
|
notifications: 'X',
|
||||||
@@ -732,6 +734,10 @@ export interface AppState {
|
|||||||
// Whether to auto-dismiss the indicator after completion (default: true)
|
// Whether to auto-dismiss the indicator after completion (default: true)
|
||||||
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Use Worktrees Override (per-project, keyed by project path)
|
||||||
|
// undefined = use global setting, true/false = project-specific override
|
||||||
|
useWorktreesByProject: Record<string, boolean | undefined>;
|
||||||
|
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
/** Whether worktree panel is collapsed in board view */
|
/** Whether worktree panel is collapsed in board view */
|
||||||
worktreePanelCollapsed: boolean;
|
worktreePanelCollapsed: boolean;
|
||||||
@@ -1185,6 +1191,11 @@ export interface AppActions {
|
|||||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||||
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||||
|
|
||||||
|
// Use Worktrees Override actions (per-project)
|
||||||
|
setProjectUseWorktrees: (projectPath: string, useWorktrees: boolean | null) => void; // null = use global
|
||||||
|
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
|
||||||
|
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
|
||||||
|
|
||||||
// UI State actions (previously in localStorage, now synced via API)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||||
setLastProjectDir: (dir: string) => void;
|
setLastProjectDir: (dir: string) => void;
|
||||||
@@ -1345,6 +1356,7 @@ const initialState: AppState = {
|
|||||||
showInitScriptIndicatorByProject: {},
|
showInitScriptIndicatorByProject: {},
|
||||||
defaultDeleteBranchByProject: {},
|
defaultDeleteBranchByProject: {},
|
||||||
autoDismissInitScriptIndicatorByProject: {},
|
autoDismissInitScriptIndicatorByProject: {},
|
||||||
|
useWorktreesByProject: {},
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
worktreePanelCollapsed: false,
|
worktreePanelCollapsed: false,
|
||||||
lastProjectDir: '',
|
lastProjectDir: '',
|
||||||
@@ -3528,6 +3540,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Use Worktrees Override actions (per-project)
|
||||||
|
setProjectUseWorktrees: (projectPath, useWorktrees) => {
|
||||||
|
const newValue = useWorktrees === null ? undefined : useWorktrees;
|
||||||
|
set({
|
||||||
|
useWorktreesByProject: {
|
||||||
|
...get().useWorktreesByProject,
|
||||||
|
[projectPath]: newValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectUseWorktrees: (projectPath) => {
|
||||||
|
// Returns undefined if using global setting, true/false if project-specific
|
||||||
|
return get().useWorktreesByProject[projectPath];
|
||||||
|
},
|
||||||
|
|
||||||
|
getEffectiveUseWorktrees: (projectPath) => {
|
||||||
|
// Returns the actual value to use (project override or global fallback)
|
||||||
|
const projectSetting = get().useWorktreesByProject[projectPath];
|
||||||
|
if (projectSetting !== undefined) {
|
||||||
|
return projectSetting;
|
||||||
|
}
|
||||||
|
return get().useWorktrees;
|
||||||
|
},
|
||||||
|
|
||||||
// UI State actions (previously in localStorage, now synced via API)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ export interface KeyboardShortcuts {
|
|||||||
context: string;
|
context: string;
|
||||||
/** Open settings */
|
/** Open settings */
|
||||||
settings: string;
|
settings: string;
|
||||||
|
/** Open project settings */
|
||||||
|
projectSettings: string;
|
||||||
/** Open terminal */
|
/** Open terminal */
|
||||||
terminal: string;
|
terminal: string;
|
||||||
/** Open notifications */
|
/** Open notifications */
|
||||||
@@ -804,6 +806,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
spec: 'D',
|
spec: 'D',
|
||||||
context: 'C',
|
context: 'C',
|
||||||
settings: 'S',
|
settings: 'S',
|
||||||
|
projectSettings: 'Shift+S',
|
||||||
terminal: 'T',
|
terminal: 'T',
|
||||||
notifications: 'X',
|
notifications: 'X',
|
||||||
toggleSidebar: '`',
|
toggleSidebar: '`',
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "1.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"apps/server": {
|
"apps/server": {
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.12.0",
|
"version": "0.10.0",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
},
|
},
|
||||||
"apps/ui": {
|
"apps/ui": {
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.12.0",
|
"version": "0.10.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11607,7 +11607,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11629,7 +11628,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11673,7 +11671,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11695,7 +11692,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11717,7 +11713,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11739,7 +11734,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11761,7 +11755,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11783,7 +11776,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11805,7 +11797,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user