mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Merge main into massive-terminal-upgrade
Resolves merge conflicts: - apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger - apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions - apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling) - apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes - apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* Dependency Resolution Utility
|
||||
*
|
||||
* Provides topological sorting and dependency analysis for features.
|
||||
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||
*/
|
||||
|
||||
import type { Feature } from "@/store/app-store";
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||
* 2. Apply Kahn's algorithm for topological sort
|
||||
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||
* 4. Detect circular dependencies for features that can't be ordered
|
||||
*
|
||||
* @param features - Array of features to order
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
const blockedFeatures = new Map<string, string[]>();
|
||||
|
||||
// Initialize graph structures
|
||||
for (const feature of features) {
|
||||
inDegree.set(feature.id, 0);
|
||||
adjacencyList.set(feature.id, []);
|
||||
}
|
||||
|
||||
// Build dependency graph and detect missing/blocked dependencies
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!featureMap.has(depId)) {
|
||||
// Missing dependency - track it
|
||||
if (!missingDependencies.has(feature.id)) {
|
||||
missingDependencies.set(feature.id, []);
|
||||
}
|
||||
missingDependencies.get(feature.id)!.push(depId);
|
||||
} else {
|
||||
// Valid dependency - add edge to graph
|
||||
adjacencyList.get(depId)!.push(feature.id);
|
||||
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||
|
||||
// Check if dependency is incomplete (blocking)
|
||||
const depFeature = featureMap.get(depId)!;
|
||||
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||
if (!blockedFeatures.has(feature.id)) {
|
||||
blockedFeatures.set(feature.id, []);
|
||||
}
|
||||
blockedFeatures.get(feature.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with priority-aware selection
|
||||
const queue: Feature[] = [];
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort initial queue by priority
|
||||
queue.sort(sortByPriority);
|
||||
|
||||
// Process features in topological order
|
||||
while (queue.length > 0) {
|
||||
// Take highest priority feature from queue
|
||||
const current = queue.shift()!;
|
||||
orderedFeatures.push(current);
|
||||
|
||||
// Process features that depend on this one
|
||||
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||
const currentDegree = inDegree.get(dependentId);
|
||||
if (currentDegree === undefined) {
|
||||
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||
}
|
||||
const newDegree = currentDegree - 1;
|
||||
inDegree.set(dependentId, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(featureMap.get(dependentId)!);
|
||||
// Re-sort queue to maintain priority order
|
||||
queue.sort(sortByPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
// Add remaining features at end (part of cycles)
|
||||
orderedFeatures.push(...remaining);
|
||||
}
|
||||
|
||||
return {
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects circular dependencies using depth-first search
|
||||
*
|
||||
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const currentPath: string[] = [];
|
||||
|
||||
function dfs(featureId: string): boolean {
|
||||
visited.add(featureId);
|
||||
recursionStack.add(featureId);
|
||||
currentPath.push(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (feature) {
|
||||
for (const depId of feature.dependencies || []) {
|
||||
if (!visited.has(depId)) {
|
||||
if (dfs(depId)) return true;
|
||||
} else if (recursionStack.has(depId)) {
|
||||
// Found cycle - extract it
|
||||
const cycleStart = currentPath.indexOf(depId);
|
||||
cycles.push(currentPath.slice(cycleStart));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop();
|
||||
recursionStack.delete(featureId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (!visited.has(feature.id)) {
|
||||
dfs(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every(depId => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter(depId => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* File Picker Utility for Web Browsers
|
||||
*
|
||||
*
|
||||
* Provides cross-platform file and directory selection using:
|
||||
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
|
||||
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
|
||||
*
|
||||
*
|
||||
* Note: Browsers don't expose absolute file paths for security reasons.
|
||||
* This implementation extracts directory information and may require
|
||||
* user confirmation or server-side path resolution.
|
||||
@@ -22,7 +22,7 @@ export interface DirectoryPickerResult {
|
||||
/**
|
||||
* Opens a directory picker dialog
|
||||
* @returns Promise resolving to directory information, or null if canceled
|
||||
*
|
||||
*
|
||||
* Note: Browsers don't expose absolute file paths for security reasons.
|
||||
* This function returns directory structure information that the server
|
||||
* can use to locate the actual directory path.
|
||||
@@ -31,10 +31,10 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
||||
// Use webkitdirectory (works on Windows and all modern browsers)
|
||||
return new Promise<DirectoryPickerResult | null>((resolve) => {
|
||||
let resolved = false;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.webkitdirectory = true;
|
||||
input.style.display = "none";
|
||||
input.style.display = 'none';
|
||||
|
||||
const cleanup = () => {
|
||||
if (input.parentNode) {
|
||||
@@ -58,62 +58,59 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("change", (e) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
changeEventFired = true;
|
||||
if (focusTimeout) {
|
||||
clearTimeout(focusTimeout);
|
||||
focusTimeout = null;
|
||||
}
|
||||
|
||||
console.log("[FilePicker] Change event fired");
|
||||
|
||||
console.log('[FilePicker] Change event fired');
|
||||
const files = input.files;
|
||||
console.log("[FilePicker] Files selected:", files?.length || 0);
|
||||
|
||||
console.log('[FilePicker] Files selected:', files?.length || 0);
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.log("[FilePicker] No files selected");
|
||||
console.log('[FilePicker] No files selected');
|
||||
safeResolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstFile = files[0];
|
||||
console.log("[FilePicker] First file:", {
|
||||
console.log('[FilePicker] First file:', {
|
||||
name: firstFile.name,
|
||||
webkitRelativePath: firstFile.webkitRelativePath,
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||
path: firstFile.path,
|
||||
});
|
||||
|
||||
// Extract directory name from webkitRelativePath
|
||||
// webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
|
||||
let directoryName = "Selected Directory";
|
||||
|
||||
let directoryName = 'Selected Directory';
|
||||
|
||||
// Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium)
|
||||
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||
if (firstFile.path) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||
const filePath = firstFile.path as string;
|
||||
console.log("[FilePicker] Found file.path:", filePath);
|
||||
console.log('[FilePicker] Found file.path:', filePath);
|
||||
// Extract directory path (remove filename)
|
||||
const lastSeparator = Math.max(
|
||||
filePath.lastIndexOf("\\"),
|
||||
filePath.lastIndexOf("/")
|
||||
);
|
||||
const lastSeparator = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
|
||||
if (lastSeparator > 0) {
|
||||
const absolutePath = filePath.substring(0, lastSeparator);
|
||||
console.log("[FilePicker] Found absolute path:", absolutePath);
|
||||
console.log('[FilePicker] Found absolute path:', absolutePath);
|
||||
// Return as directory name for now - server can validate it directly
|
||||
directoryName = absolutePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Extract directory name from webkitRelativePath
|
||||
if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
|
||||
if (directoryName === 'Selected Directory' && firstFile.webkitRelativePath) {
|
||||
const relativePath = firstFile.webkitRelativePath;
|
||||
console.log("[FilePicker] Using webkitRelativePath:", relativePath);
|
||||
const pathParts = relativePath.split("/");
|
||||
console.log('[FilePicker] Using webkitRelativePath:', relativePath);
|
||||
const pathParts = relativePath.split('/');
|
||||
if (pathParts.length > 0) {
|
||||
directoryName = pathParts[0]; // Top-level directory name
|
||||
console.log("[FilePicker] Extracted directory name:", directoryName);
|
||||
console.log('[FilePicker] Extracted directory name:', directoryName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +127,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[FilePicker] Directory info:", {
|
||||
console.log('[FilePicker] Directory info:', {
|
||||
directoryName,
|
||||
fileCount: files.length,
|
||||
sampleFiles: sampleFiles.slice(0, 5), // Log first 5
|
||||
@@ -150,7 +147,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
||||
// Only resolve as canceled if change event hasn't fired after a delay
|
||||
focusTimeout = setTimeout(() => {
|
||||
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
|
||||
console.log("[FilePicker] Dialog canceled (no files after focus and no change event)");
|
||||
console.log('[FilePicker] Dialog canceled (no files after focus and no change event)');
|
||||
safeResolve(null);
|
||||
}
|
||||
}, 2000); // Increased timeout for Windows - give it time
|
||||
@@ -158,33 +155,37 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
||||
|
||||
// Add to DOM temporarily
|
||||
document.body.appendChild(input);
|
||||
console.log("[FilePicker] Opening directory picker...");
|
||||
console.log('[FilePicker] Opening directory picker...');
|
||||
|
||||
// Try to show picker programmatically
|
||||
if ("showPicker" in HTMLInputElement.prototype) {
|
||||
if ('showPicker' in HTMLInputElement.prototype) {
|
||||
try {
|
||||
(input as any).showPicker();
|
||||
console.log("[FilePicker] Using showPicker()");
|
||||
console.log('[FilePicker] Using showPicker()');
|
||||
} catch (error) {
|
||||
console.log("[FilePicker] showPicker() failed, using click()", error);
|
||||
console.log('[FilePicker] showPicker() failed, using click()', error);
|
||||
input.click();
|
||||
}
|
||||
} else {
|
||||
console.log("[FilePicker] Using click()");
|
||||
console.log('[FilePicker] Using click()');
|
||||
input.click();
|
||||
}
|
||||
|
||||
// Set up cancellation detection with longer delay
|
||||
// Only add focus listener if we're not already resolved
|
||||
window.addEventListener("focus", handleFocus, { once: true });
|
||||
|
||||
window.addEventListener('focus', handleFocus, { once: true });
|
||||
|
||||
// Also handle blur as a cancellation signal (but with delay)
|
||||
window.addEventListener("blur", () => {
|
||||
// Dialog opened, wait for it to close
|
||||
setTimeout(() => {
|
||||
window.addEventListener("focus", handleFocus, { once: true });
|
||||
}, 100);
|
||||
}, { once: true });
|
||||
window.addEventListener(
|
||||
'blur',
|
||||
() => {
|
||||
// Dialog opened, wait for it to close
|
||||
setTimeout(() => {
|
||||
window.addEventListener('focus', handleFocus, { once: true });
|
||||
}, 100);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,21 +194,19 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
||||
* @param options Optional configuration (multiple files, file types, etc.)
|
||||
* @returns Promise resolving to selected file path(s), or null if canceled
|
||||
*/
|
||||
export async function openFilePicker(
|
||||
options?: {
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
}
|
||||
): Promise<string | string[] | null> {
|
||||
export async function openFilePicker(options?: {
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
}): Promise<string | string[] | null> {
|
||||
// Use standard file input (works on all browsers including Windows)
|
||||
return new Promise<string | string[] | null>((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = options?.multiple ?? false;
|
||||
if (options?.accept) {
|
||||
input.accept = options.accept;
|
||||
}
|
||||
input.style.display = "none";
|
||||
input.style.display = 'none';
|
||||
|
||||
const cleanup = () => {
|
||||
if (input.parentNode) {
|
||||
@@ -215,7 +214,7 @@ export async function openFilePicker(
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
input.addEventListener('change', () => {
|
||||
const files = input.files;
|
||||
if (!files || files.length === 0) {
|
||||
cleanup();
|
||||
@@ -228,7 +227,7 @@ export async function openFilePicker(
|
||||
// Try to get path from File object (non-standard, but available in some browsers)
|
||||
// @ts-expect-error - path property is non-standard
|
||||
if (file.path) {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||
return file.path as string;
|
||||
}
|
||||
// Fallback to filename (server will need to resolve)
|
||||
@@ -262,7 +261,7 @@ export async function openFilePicker(
|
||||
// Try to show picker programmatically
|
||||
// Note: showPicker() is available in modern browsers but TypeScript types it as void
|
||||
// In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch
|
||||
if ("showPicker" in HTMLInputElement.prototype) {
|
||||
if ('showPicker' in HTMLInputElement.prototype) {
|
||||
try {
|
||||
(input as any).showPicker();
|
||||
} catch {
|
||||
@@ -274,6 +273,6 @@ export async function openFilePicker(
|
||||
}
|
||||
|
||||
// Set up cancellation detection
|
||||
window.addEventListener("focus", handleFocus, { once: true });
|
||||
window.addEventListener('focus', handleFocus, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,41 +20,35 @@ import type {
|
||||
AutoModeEvent,
|
||||
SuggestionsEvent,
|
||||
SpecRegenerationEvent,
|
||||
FeatureSuggestion,
|
||||
SuggestionType,
|
||||
} from "./electron";
|
||||
import type { Message, SessionListItem } from "@/types/electron";
|
||||
import type { Feature } from "@/store/app-store";
|
||||
import type {
|
||||
WorktreeAPI,
|
||||
GitAPI,
|
||||
ModelDefinition,
|
||||
ProviderStatus,
|
||||
} from "@/types/electron";
|
||||
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
|
||||
} from './electron';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||
|
||||
// Server URL - configurable via environment variable
|
||||
const getServerUrl = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof window !== 'undefined') {
|
||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
}
|
||||
return "http://localhost:3008";
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
|
||||
// Get API key from environment variable
|
||||
const getApiKey = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof window !== 'undefined') {
|
||||
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EventType =
|
||||
| "agent:stream"
|
||||
| "auto-mode:event"
|
||||
| "suggestions:event"
|
||||
| "spec-regeneration:event";
|
||||
| 'agent:stream'
|
||||
| 'auto-mode:event'
|
||||
| 'suggestions:event'
|
||||
| 'spec-regeneration:event';
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
@@ -80,21 +74,18 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (
|
||||
this.isConnecting ||
|
||||
(this.ws && this.ws.readyState === WebSocket.OPEN)
|
||||
) {
|
||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
|
||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[HttpApiClient] WebSocket connected");
|
||||
console.log('[HttpApiClient] WebSocket connected');
|
||||
this.isConnecting = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
@@ -110,15 +101,12 @@ export class HttpApiClient implements ElectronAPI {
|
||||
callbacks.forEach((cb) => cb(data.payload));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[HttpApiClient] Failed to parse WebSocket message:",
|
||||
error
|
||||
);
|
||||
console.error('[HttpApiClient] Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[HttpApiClient] WebSocket disconnected");
|
||||
console.log('[HttpApiClient] WebSocket disconnected');
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
// Attempt to reconnect after 5 seconds
|
||||
@@ -131,19 +119,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("[HttpApiClient] WebSocket error:", error);
|
||||
console.error('[HttpApiClient] WebSocket error:', error);
|
||||
this.isConnecting = false;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[HttpApiClient] Failed to create WebSocket:", error);
|
||||
console.error('[HttpApiClient] Failed to create WebSocket:', error);
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToEvent(
|
||||
type: EventType,
|
||||
callback: EventCallback
|
||||
): () => void {
|
||||
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
|
||||
if (!this.eventCallbacks.has(type)) {
|
||||
this.eventCallbacks.set(type, new Set());
|
||||
}
|
||||
@@ -162,18 +147,18 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers["X-API-Key"] = apiKey;
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
@@ -188,7 +173,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
@@ -197,7 +182,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "DELETE",
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
return response.json();
|
||||
@@ -205,15 +190,13 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
// Basic operations
|
||||
async ping(): Promise<string> {
|
||||
const result = await this.get<{ status: string }>("/api/health");
|
||||
return result.status === "ok" ? "pong" : "error";
|
||||
const result = await this.get<{ status: string }>('/api/health');
|
||||
return result.status === 'ok' ? 'pong' : 'error';
|
||||
}
|
||||
|
||||
async openExternalLink(
|
||||
url: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Open in new tab
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -241,9 +224,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
try {
|
||||
// Use anchor click approach which is most reliable for custom URL schemes
|
||||
// This triggers the browser's URL handler without navigation issues
|
||||
const anchor = document.createElement("a");
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.style.display = "none";
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
@@ -251,7 +234,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to open in editor",
|
||||
error: error instanceof Error ? error.message : 'Failed to open in editor',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -261,7 +244,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const fileBrowser = getGlobalFileBrowser();
|
||||
|
||||
if (!fileBrowser) {
|
||||
console.error("File browser not initialized");
|
||||
console.error('File browser not initialized');
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
@@ -276,21 +259,21 @@ export class HttpApiClient implements ElectronAPI {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>("/api/fs/validate-path", { filePath: path });
|
||||
}>('/api/fs/validate-path', { filePath: path });
|
||||
|
||||
if (result.success && result.path) {
|
||||
return { canceled: false, filePaths: [result.path] };
|
||||
}
|
||||
|
||||
console.error("Invalid directory:", result.error);
|
||||
console.error('Invalid directory:', result.error);
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
async openFile(options?: object): Promise<DialogResult> {
|
||||
async openFile(_options?: object): Promise<DialogResult> {
|
||||
const fileBrowser = getGlobalFileBrowser();
|
||||
|
||||
if (!fileBrowser) {
|
||||
console.error("File browser not initialized");
|
||||
console.error('File browser not initialized');
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
@@ -301,50 +284,48 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
||||
"/api/fs/exists",
|
||||
{ filePath: path }
|
||||
);
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
|
||||
filePath: path,
|
||||
});
|
||||
|
||||
if (result.success && result.exists) {
|
||||
return { canceled: false, filePaths: [path] };
|
||||
}
|
||||
|
||||
console.error("File not found");
|
||||
console.error('File not found');
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
// File system operations
|
||||
async readFile(filePath: string): Promise<FileResult> {
|
||||
return this.post("/api/fs/read", { filePath });
|
||||
return this.post('/api/fs/read', { filePath });
|
||||
}
|
||||
|
||||
async writeFile(filePath: string, content: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/write", { filePath, content });
|
||||
return this.post('/api/fs/write', { filePath, content });
|
||||
}
|
||||
|
||||
async mkdir(dirPath: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/mkdir", { dirPath });
|
||||
return this.post('/api/fs/mkdir', { dirPath });
|
||||
}
|
||||
|
||||
async readdir(dirPath: string): Promise<ReaddirResult> {
|
||||
return this.post("/api/fs/readdir", { dirPath });
|
||||
return this.post('/api/fs/readdir', { dirPath });
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
||||
"/api/fs/exists",
|
||||
{ filePath }
|
||||
);
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
|
||||
filePath,
|
||||
});
|
||||
return result.exists;
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<StatResult> {
|
||||
return this.post("/api/fs/stat", { filePath });
|
||||
return this.post('/api/fs/stat', { filePath });
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/delete", { filePath });
|
||||
return this.post('/api/fs/delete', { filePath });
|
||||
}
|
||||
|
||||
async trashItem(filePath: string): Promise<WriteResult> {
|
||||
@@ -354,11 +335,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
async getPath(name: string): Promise<string> {
|
||||
// Server provides data directory
|
||||
if (name === "userData") {
|
||||
const result = await this.get<{ dataDir: string }>(
|
||||
"/api/health/detailed"
|
||||
);
|
||||
return result.dataDir || "/data";
|
||||
if (name === 'userData') {
|
||||
const result = await this.get<{ dataDir: string }>('/api/health/detailed');
|
||||
return result.dataDir || '/data';
|
||||
}
|
||||
return `/data/${name}`;
|
||||
}
|
||||
@@ -369,7 +348,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
mimeType: string,
|
||||
projectPath?: string
|
||||
): Promise<SaveImageResult> {
|
||||
return this.post("/api/fs/save-image", {
|
||||
return this.post('/api/fs/save-image', {
|
||||
data,
|
||||
filename,
|
||||
mimeType,
|
||||
@@ -383,7 +362,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
mimeType: string,
|
||||
projectPath: string
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> {
|
||||
return this.post("/api/fs/save-board-background", {
|
||||
return this.post('/api/fs/save-board-background', {
|
||||
data,
|
||||
filename,
|
||||
mimeType,
|
||||
@@ -391,10 +370,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBoardBackground(
|
||||
projectPath: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return this.post("/api/fs/delete-board-background", { projectPath });
|
||||
async deleteBoardBackground(projectPath: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.post('/api/fs/delete-board-background', { projectPath });
|
||||
}
|
||||
|
||||
// CLI checks - server-side
|
||||
@@ -413,7 +390,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
return this.get("/api/setup/claude-status");
|
||||
return this.get('/api/setup/claude-status');
|
||||
}
|
||||
|
||||
// Model API
|
||||
@@ -423,14 +400,14 @@ export class HttpApiClient implements ElectronAPI {
|
||||
models?: ModelDefinition[];
|
||||
error?: string;
|
||||
}> => {
|
||||
return this.get("/api/models/available");
|
||||
return this.get('/api/models/available');
|
||||
},
|
||||
checkProviders: async (): Promise<{
|
||||
success: boolean;
|
||||
providers?: Record<string, ProviderStatus>;
|
||||
error?: string;
|
||||
}> => {
|
||||
return this.get("/api/models/providers");
|
||||
return this.get('/api/models/providers');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -456,13 +433,13 @@ export class HttpApiClient implements ElectronAPI {
|
||||
hasRecentActivity?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/claude-status"),
|
||||
}> => this.get('/api/setup/claude-status'),
|
||||
|
||||
installClaude: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/install-claude"),
|
||||
}> => this.post('/api/setup/install-claude'),
|
||||
|
||||
authClaude: (): Promise<{
|
||||
success: boolean;
|
||||
@@ -473,7 +450,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}> => this.post("/api/setup/auth-claude"),
|
||||
}> => this.post('/api/setup/auth-claude'),
|
||||
|
||||
storeApiKey: (
|
||||
provider: string,
|
||||
@@ -481,7 +458,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
||||
}> => this.post('/api/setup/store-api-key', { provider, apiKey }),
|
||||
|
||||
deleteApiKey: (
|
||||
provider: string
|
||||
@@ -489,13 +466,13 @@ export class HttpApiClient implements ElectronAPI {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}> => this.post("/api/setup/delete-api-key", { provider }),
|
||||
}> => this.post('/api/setup/delete-api-key', { provider }),
|
||||
|
||||
getApiKeys: (): Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}> => this.get("/api/setup/api-keys"),
|
||||
}> => this.get('/api/setup/api-keys'),
|
||||
|
||||
getPlatform: (): Promise<{
|
||||
success: boolean;
|
||||
@@ -505,15 +482,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
isWindows: boolean;
|
||||
isMac: boolean;
|
||||
isLinux: boolean;
|
||||
}> => this.get("/api/setup/platform"),
|
||||
}> => this.get('/api/setup/platform'),
|
||||
|
||||
verifyClaudeAuth: (
|
||||
authMethod?: "cli" | "api_key"
|
||||
authMethod?: 'cli' | 'api_key'
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
|
||||
}> => this.post('/api/setup/verify-claude-auth', { authMethod }),
|
||||
|
||||
getGhStatus: (): Promise<{
|
||||
success: boolean;
|
||||
@@ -523,76 +500,65 @@ export class HttpApiClient implements ElectronAPI {
|
||||
path: string | null;
|
||||
user: string | null;
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/gh-status"),
|
||||
}> => this.get('/api/setup/gh-status'),
|
||||
|
||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback);
|
||||
return this.subscribeToEvent('agent:stream', callback);
|
||||
},
|
||||
|
||||
onAuthProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback);
|
||||
return this.subscribeToEvent('agent:stream', callback);
|
||||
},
|
||||
};
|
||||
|
||||
// Features API
|
||||
features: FeaturesAPI = {
|
||||
getAll: (projectPath: string) =>
|
||||
this.post("/api/features/list", { projectPath }),
|
||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||
get: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/get", { projectPath, featureId }),
|
||||
this.post('/api/features/get', { projectPath, featureId }),
|
||||
create: (projectPath: string, feature: Feature) =>
|
||||
this.post("/api/features/create", { projectPath, feature }),
|
||||
update: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
) => this.post("/api/features/update", { projectPath, featureId, updates }),
|
||||
this.post('/api/features/create', { projectPath, feature }),
|
||||
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
|
||||
this.post('/api/features/update', { projectPath, featureId, updates }),
|
||||
delete: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/delete", { projectPath, featureId }),
|
||||
this.post('/api/features/delete', { projectPath, featureId }),
|
||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/agent-output", { projectPath, featureId }),
|
||||
this.post('/api/features/agent-output', { projectPath, featureId }),
|
||||
generateTitle: (description: string) =>
|
||||
this.post("/api/features/generate-title", { description }),
|
||||
this.post('/api/features/generate-title', { description }),
|
||||
};
|
||||
|
||||
// Auto Mode API
|
||||
autoMode: AutoModeAPI = {
|
||||
start: (projectPath: string, maxConcurrency?: number) =>
|
||||
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
|
||||
stop: (projectPath: string) =>
|
||||
this.post("/api/auto-mode/stop", { projectPath }),
|
||||
stopFeature: (featureId: string) =>
|
||||
this.post("/api/auto-mode/stop-feature", { featureId }),
|
||||
status: (projectPath?: string) =>
|
||||
this.post("/api/auto-mode/status", { projectPath }),
|
||||
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
|
||||
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
|
||||
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
|
||||
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
|
||||
runFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean,
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/run-feature", {
|
||||
this.post('/api/auto-mode/run-feature', {
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
worktreePath,
|
||||
}),
|
||||
verifyFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
||||
resumeFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean
|
||||
) =>
|
||||
this.post("/api/auto-mode/resume-feature", {
|
||||
this.post('/api/auto-mode/verify-feature', { projectPath, featureId }),
|
||||
resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
|
||||
this.post('/api/auto-mode/resume-feature', {
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
}),
|
||||
contextExists: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
|
||||
this.post('/api/auto-mode/context-exists', { projectPath, featureId }),
|
||||
analyzeProject: (projectPath: string) =>
|
||||
this.post("/api/auto-mode/analyze-project", { projectPath }),
|
||||
this.post('/api/auto-mode/analyze-project', { projectPath }),
|
||||
followUpFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
@@ -600,19 +566,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
imagePaths?: string[],
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/follow-up-feature", {
|
||||
this.post('/api/auto-mode/follow-up-feature', {
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
worktreePath,
|
||||
}),
|
||||
commitFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/commit-feature", {
|
||||
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
||||
this.post('/api/auto-mode/commit-feature', {
|
||||
projectPath,
|
||||
featureId,
|
||||
worktreePath,
|
||||
@@ -624,7 +586,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/approve-plan", {
|
||||
this.post('/api/auto-mode/approve-plan', {
|
||||
projectPath,
|
||||
featureId,
|
||||
approved,
|
||||
@@ -632,10 +594,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
feedback,
|
||||
}),
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"auto-mode:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -646,7 +605,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
enhancementMode: string,
|
||||
model?: string
|
||||
): Promise<EnhancePromptResult> =>
|
||||
this.post("/api/enhance-prompt", {
|
||||
this.post('/api/enhance-prompt', {
|
||||
originalText,
|
||||
enhancementMode,
|
||||
model,
|
||||
@@ -656,86 +615,74 @@ export class HttpApiClient implements ElectronAPI {
|
||||
// Worktree API
|
||||
worktree: WorktreeAPI = {
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
||||
this.post("/api/worktree/merge", { projectPath, featureId, options }),
|
||||
this.post('/api/worktree/merge', { projectPath, featureId, options }),
|
||||
getInfo: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/info", { projectPath, featureId }),
|
||||
this.post('/api/worktree/info', { projectPath, featureId }),
|
||||
getStatus: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/status", { projectPath, featureId }),
|
||||
list: (projectPath: string) =>
|
||||
this.post("/api/worktree/list", { projectPath }),
|
||||
this.post('/api/worktree/status', { projectPath, featureId }),
|
||||
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
|
||||
listAll: (projectPath: string, includeDetails?: boolean) =>
|
||||
this.post("/api/worktree/list", { projectPath, includeDetails }),
|
||||
this.post('/api/worktree/list', { projectPath, includeDetails }),
|
||||
create: (projectPath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post("/api/worktree/create", {
|
||||
this.post('/api/worktree/create', {
|
||||
projectPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
}),
|
||||
delete: (
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch?: boolean
|
||||
) =>
|
||||
this.post("/api/worktree/delete", {
|
||||
delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) =>
|
||||
this.post('/api/worktree/delete', {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
}),
|
||||
commit: (worktreePath: string, message: string) =>
|
||||
this.post("/api/worktree/commit", { worktreePath, message }),
|
||||
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||
push: (worktreePath: string, force?: boolean) =>
|
||||
this.post("/api/worktree/push", { worktreePath, force }),
|
||||
this.post('/api/worktree/push', { worktreePath, force }),
|
||||
createPR: (worktreePath: string, options?: any) =>
|
||||
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
|
||||
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
|
||||
getDiffs: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/diffs", { projectPath, featureId }),
|
||||
this.post('/api/worktree/diffs', { projectPath, featureId }),
|
||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
||||
this.post("/api/worktree/file-diff", {
|
||||
this.post('/api/worktree/file-diff', {
|
||||
projectPath,
|
||||
featureId,
|
||||
filePath,
|
||||
}),
|
||||
pull: (worktreePath: string) =>
|
||||
this.post("/api/worktree/pull", { worktreePath }),
|
||||
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
|
||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
|
||||
listBranches: (worktreePath: string) =>
|
||||
this.post("/api/worktree/list-branches", { worktreePath }),
|
||||
this.post('/api/worktree/list-branches', { worktreePath }),
|
||||
switchBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
|
||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||
openInEditor: (worktreePath: string) =>
|
||||
this.post("/api/worktree/open-in-editor", { worktreePath }),
|
||||
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
|
||||
initGit: (projectPath: string) =>
|
||||
this.post("/api/worktree/init-git", { projectPath }),
|
||||
this.post('/api/worktree/open-in-editor', { worktreePath }),
|
||||
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
||||
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
|
||||
stopDevServer: (worktreePath: string) =>
|
||||
this.post("/api/worktree/stop-dev", { worktreePath }),
|
||||
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
|
||||
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
|
||||
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
||||
};
|
||||
|
||||
// Git API
|
||||
git: GitAPI = {
|
||||
getDiffs: (projectPath: string) =>
|
||||
this.post("/api/git/diffs", { projectPath }),
|
||||
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
|
||||
getFileDiff: (projectPath: string, filePath: string) =>
|
||||
this.post("/api/git/file-diff", { projectPath, filePath }),
|
||||
this.post('/api/git/file-diff', { projectPath, filePath }),
|
||||
};
|
||||
|
||||
// Suggestions API
|
||||
suggestions: SuggestionsAPI = {
|
||||
generate: (projectPath: string, suggestionType?: SuggestionType) =>
|
||||
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
|
||||
stop: () => this.post("/api/suggestions/stop"),
|
||||
status: () => this.get("/api/suggestions/status"),
|
||||
this.post('/api/suggestions/generate', { projectPath, suggestionType }),
|
||||
stop: () => this.post('/api/suggestions/stop'),
|
||||
status: () => this.get('/api/suggestions/status'),
|
||||
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"suggestions:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
return this.subscribeToEvent('suggestions:event', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -748,7 +695,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number
|
||||
) =>
|
||||
this.post("/api/spec-regeneration/create", {
|
||||
this.post('/api/spec-regeneration/create', {
|
||||
projectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
@@ -762,7 +709,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number
|
||||
) =>
|
||||
this.post("/api/spec-regeneration/generate", {
|
||||
this.post('/api/spec-regeneration/generate', {
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
generateFeatures,
|
||||
@@ -770,17 +717,14 @@ export class HttpApiClient implements ElectronAPI {
|
||||
maxFeatures,
|
||||
}),
|
||||
generateFeatures: (projectPath: string, maxFeatures?: number) =>
|
||||
this.post("/api/spec-regeneration/generate-features", {
|
||||
this.post('/api/spec-regeneration/generate-features', {
|
||||
projectPath,
|
||||
maxFeatures,
|
||||
}),
|
||||
stop: () => this.post("/api/spec-regeneration/stop"),
|
||||
status: () => this.get("/api/spec-regeneration/status"),
|
||||
stop: () => this.post('/api/spec-regeneration/stop'),
|
||||
status: () => this.get('/api/spec-regeneration/status'),
|
||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"spec-regeneration:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -796,7 +740,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}>;
|
||||
totalCount?: number;
|
||||
error?: string;
|
||||
}> => this.get("/api/running-agents"),
|
||||
}> => this.get('/api/running-agents'),
|
||||
};
|
||||
|
||||
// Workspace API
|
||||
@@ -805,14 +749,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
success: boolean;
|
||||
configured: boolean;
|
||||
workspaceDir?: string;
|
||||
defaultDir?: string | null;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/config"),
|
||||
}> => this.get('/api/workspace/config'),
|
||||
|
||||
getDirectories: (): Promise<{
|
||||
success: boolean;
|
||||
directories?: Array<{ name: string; path: string }>;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/directories"),
|
||||
}> => this.get('/api/workspace/directories'),
|
||||
};
|
||||
|
||||
// Agent API
|
||||
@@ -824,7 +769,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
error?: string;
|
||||
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
|
||||
}> => this.post('/api/agent/start', { sessionId, workingDirectory }),
|
||||
|
||||
send: (
|
||||
sessionId: string,
|
||||
@@ -833,7 +778,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
imagePaths?: string[],
|
||||
model?: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/send", {
|
||||
this.post('/api/agent/send', {
|
||||
sessionId,
|
||||
message,
|
||||
workingDirectory,
|
||||
@@ -848,16 +793,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
messages?: Message[];
|
||||
isRunning?: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/agent/history", { sessionId }),
|
||||
}> => this.post('/api/agent/history', { sessionId }),
|
||||
|
||||
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/stop", { sessionId }),
|
||||
this.post('/api/agent/stop', { sessionId }),
|
||||
|
||||
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/clear", { sessionId }),
|
||||
this.post('/api/agent/clear', { sessionId }),
|
||||
|
||||
onStream: (callback: (data: unknown) => void): (() => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback as EventCallback);
|
||||
return this.subscribeToEvent('agent:stream', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -872,8 +817,140 @@ export class HttpApiClient implements ElectronAPI {
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
}> =>
|
||||
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
|
||||
}> => this.post('/api/templates/clone', { repoUrl, projectName, parentDir }),
|
||||
};
|
||||
|
||||
// Settings API - persistent file-based settings
|
||||
settings = {
|
||||
// Get settings status (check if migration needed)
|
||||
getStatus: (): Promise<{
|
||||
success: boolean;
|
||||
hasGlobalSettings: boolean;
|
||||
hasCredentials: boolean;
|
||||
dataDir: string;
|
||||
needsMigration: boolean;
|
||||
}> => this.get('/api/settings/status'),
|
||||
|
||||
// Global settings
|
||||
getGlobal: (): Promise<{
|
||||
success: boolean;
|
||||
settings?: {
|
||||
version: number;
|
||||
theme: string;
|
||||
sidebarOpen: boolean;
|
||||
chatHistoryOpen: boolean;
|
||||
kanbanCardDetailLevel: string;
|
||||
maxConcurrency: number;
|
||||
defaultSkipTests: boolean;
|
||||
enableDependencyBlocking: boolean;
|
||||
useWorktrees: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
defaultPlanningMode: string;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultAIProfileId: string | null;
|
||||
muteDoneSound: boolean;
|
||||
enhancementModel: string;
|
||||
keyboardShortcuts: Record<string, string>;
|
||||
aiProfiles: unknown[];
|
||||
projects: unknown[];
|
||||
trashedProjects: unknown[];
|
||||
projectHistory: string[];
|
||||
projectHistoryIndex: number;
|
||||
lastProjectDir?: string;
|
||||
recentFolders: string[];
|
||||
worktreePanelCollapsed: boolean;
|
||||
lastSelectedSessionByProject: Record<string, string>;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get('/api/settings/global'),
|
||||
|
||||
updateGlobal: (
|
||||
updates: Record<string, unknown>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}> => this.put('/api/settings/global', updates),
|
||||
|
||||
// Credentials (masked for security)
|
||||
getCredentials: (): Promise<{
|
||||
success: boolean;
|
||||
credentials?: {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get('/api/settings/credentials'),
|
||||
|
||||
updateCredentials: (updates: {
|
||||
apiKeys?: { anthropic?: string; google?: string; openai?: string };
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
credentials?: {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
};
|
||||
error?: string;
|
||||
}> => this.put('/api/settings/credentials', updates),
|
||||
|
||||
// Project settings
|
||||
getProject: (
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
settings?: {
|
||||
version: number;
|
||||
theme?: string;
|
||||
useWorktrees?: boolean;
|
||||
currentWorktree?: { path: string | null; branch: string };
|
||||
worktrees?: Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>;
|
||||
boardBackground?: {
|
||||
imagePath: string | null;
|
||||
imageVersion?: number;
|
||||
cardOpacity: number;
|
||||
columnOpacity: number;
|
||||
columnBorderEnabled: boolean;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
hideScrollbar: boolean;
|
||||
};
|
||||
lastSelectedSessionId?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post('/api/settings/project', { projectPath }),
|
||||
|
||||
updateProject: (
|
||||
projectPath: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}> => this.put('/api/settings/project', { projectPath, updates }),
|
||||
|
||||
// Migration from localStorage
|
||||
migrate: (data: {
|
||||
'automaker-storage'?: string;
|
||||
'automaker-setup'?: string;
|
||||
'worktree-panel-collapsed'?: string;
|
||||
'file-browser-recent-folders'?: string;
|
||||
'automaker:lastProjectDir'?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
migratedCredentials: boolean;
|
||||
migratedProjectCount: number;
|
||||
errors: string[];
|
||||
}> => this.post('/api/settings/migrate', { data }),
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
@@ -901,7 +978,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
updatedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
|
||||
}> => this.post('/api/sessions', { name, projectPath, workingDirectory }),
|
||||
|
||||
update: (
|
||||
sessionId: string,
|
||||
@@ -910,21 +987,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
||||
|
||||
archive: (
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
||||
|
||||
unarchive: (
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
||||
|
||||
delete: (
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||
};
|
||||
|
||||
// Claude API
|
||||
claude = {
|
||||
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* new or existing projects.
|
||||
*/
|
||||
|
||||
import { getElectronAPI } from "./electron";
|
||||
import { getElectronAPI } from './electron';
|
||||
|
||||
export interface ProjectInitResult {
|
||||
success: boolean;
|
||||
@@ -23,14 +23,9 @@ const REQUIRED_STRUCTURE: {
|
||||
directories: string[];
|
||||
files: Record<string, string>;
|
||||
} = {
|
||||
directories: [
|
||||
".automaker",
|
||||
".automaker/context",
|
||||
".automaker/features",
|
||||
".automaker/images",
|
||||
],
|
||||
directories: ['.automaker', '.automaker/context', '.automaker/features', '.automaker/images'],
|
||||
files: {
|
||||
".automaker/categories.json": "[]",
|
||||
'.automaker/categories.json': '[]',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,37 +35,63 @@ const REQUIRED_STRUCTURE: {
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Result indicating what was created or if the project was already initialized
|
||||
*/
|
||||
export async function initializeProject(
|
||||
projectPath: string
|
||||
): Promise<ProjectInitResult> {
|
||||
export async function initializeProject(projectPath: string): Promise<ProjectInitResult> {
|
||||
const api = getElectronAPI();
|
||||
const createdFiles: string[] = [];
|
||||
const existingFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Validate that the project directory exists and is a directory
|
||||
const projectExists = await api.exists(projectPath);
|
||||
if (!projectExists) {
|
||||
return {
|
||||
success: false,
|
||||
isNewProject: false,
|
||||
error: `Project directory does not exist: ${projectPath}. Create it first before initializing.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify it's actually a directory (not a file)
|
||||
const projectStat = await api.stat(projectPath);
|
||||
if (!projectStat.success) {
|
||||
return {
|
||||
success: false,
|
||||
isNewProject: false,
|
||||
error: projectStat.error || `Failed to stat project directory: ${projectPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectStat.stats && !projectStat.stats.isDirectory) {
|
||||
return {
|
||||
success: false,
|
||||
isNewProject: false,
|
||||
error: `Project path is not a directory: ${projectPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize git repository if it doesn't exist
|
||||
const gitDirExists = await api.exists(`${projectPath}/.git`);
|
||||
if (!gitDirExists) {
|
||||
console.log("[project-init] Initializing git repository...");
|
||||
console.log('[project-init] Initializing git repository...');
|
||||
try {
|
||||
// Initialize git and create an initial empty commit via server route
|
||||
const result = await api.worktree?.initGit(projectPath);
|
||||
if (result?.success && result.result?.initialized) {
|
||||
createdFiles.push(".git");
|
||||
console.log("[project-init] Git repository initialized with initial commit");
|
||||
createdFiles.push('.git');
|
||||
console.log('[project-init] Git repository initialized with initial commit');
|
||||
} else if (result?.success && !result.result?.initialized) {
|
||||
// Git already existed (shouldn't happen since we checked, but handle it)
|
||||
existingFiles.push(".git");
|
||||
console.log("[project-init] Git repository already exists");
|
||||
existingFiles.push('.git');
|
||||
console.log('[project-init] Git repository already exists');
|
||||
} else {
|
||||
console.warn("[project-init] Failed to initialize git repository:", result?.error);
|
||||
console.warn('[project-init] Failed to initialize git repository:', result?.error);
|
||||
}
|
||||
} catch (gitError) {
|
||||
console.warn("[project-init] Failed to initialize git repository:", gitError);
|
||||
console.warn('[project-init] Failed to initialize git repository:', gitError);
|
||||
// Don't fail the whole initialization if git init fails
|
||||
}
|
||||
} else {
|
||||
existingFiles.push(".git");
|
||||
existingFiles.push('.git');
|
||||
}
|
||||
|
||||
// Create all required directories
|
||||
@@ -80,9 +101,7 @@ export async function initializeProject(
|
||||
}
|
||||
|
||||
// Check and create required files
|
||||
for (const [relativePath, defaultContent] of Object.entries(
|
||||
REQUIRED_STRUCTURE.files
|
||||
)) {
|
||||
for (const [relativePath, defaultContent] of Object.entries(REQUIRED_STRUCTURE.files)) {
|
||||
const fullPath = `${projectPath}/${relativePath}`;
|
||||
const exists = await api.exists(fullPath);
|
||||
|
||||
@@ -95,8 +114,7 @@ export async function initializeProject(
|
||||
}
|
||||
|
||||
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
|
||||
const isNewProject =
|
||||
createdFiles.length === 0 && existingFiles.length === 0;
|
||||
const isNewProject = createdFiles.length === 0 && existingFiles.length === 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -105,11 +123,11 @@ export async function initializeProject(
|
||||
existingFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[project-init] Failed to initialize project:", error);
|
||||
console.error('[project-init] Failed to initialize project:', error);
|
||||
return {
|
||||
success: false,
|
||||
isNewProject: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -120,9 +138,7 @@ export async function initializeProject(
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns true if all required files/directories exist
|
||||
*/
|
||||
export async function isProjectInitialized(
|
||||
projectPath: string
|
||||
): Promise<boolean> {
|
||||
export async function isProjectInitialized(projectPath: string): Promise<boolean> {
|
||||
const api = getElectronAPI();
|
||||
|
||||
try {
|
||||
@@ -137,10 +153,7 @@ export async function isProjectInitialized(
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[project-init] Error checking project initialization:",
|
||||
error
|
||||
);
|
||||
console.error('[project-init] Error checking project initialization:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -178,7 +191,7 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
|
||||
existingFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[project-init] Error getting project status:", error);
|
||||
console.error('[project-init] Error getting project status:', error);
|
||||
return {
|
||||
initialized: false,
|
||||
missingFiles: REQUIRED_STRUCTURE.directories,
|
||||
@@ -199,7 +212,7 @@ export async function hasAppSpec(projectPath: string): Promise<boolean> {
|
||||
const fullPath = `${projectPath}/.automaker/app_spec.txt`;
|
||||
return await api.exists(fullPath);
|
||||
} catch (error) {
|
||||
console.error("[project-init] Error checking app_spec.txt:", error);
|
||||
console.error('[project-init] Error checking app_spec.txt:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +229,7 @@ export async function hasAutomakerDir(projectPath: string): Promise<boolean> {
|
||||
const fullPath = `${projectPath}/.automaker`;
|
||||
return await api.exists(fullPath);
|
||||
} catch (error) {
|
||||
console.error("[project-init] Error checking .automaker dir:", error);
|
||||
console.error('[project-init] Error checking .automaker dir:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
100
apps/ui/src/lib/storage.ts
Normal file
100
apps/ui/src/lib/storage.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Centralized localStorage abstraction module
|
||||
*
|
||||
* Provides type-safe wrappers for all localStorage operations.
|
||||
* All localStorage access should go through this module to ensure
|
||||
* consistent error handling and environment checks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if localStorage is available in the current environment
|
||||
*/
|
||||
function isStorageAvailable(): boolean {
|
||||
return typeof window !== 'undefined' && window.localStorage !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from localStorage
|
||||
* @param key - The storage key
|
||||
* @returns The stored value or null if not found/unavailable
|
||||
*/
|
||||
export function getItem(key: string): string | null {
|
||||
if (!isStorageAvailable()) return null;
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an item in localStorage
|
||||
* @param key - The storage key
|
||||
* @param value - The value to store
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
export function setItem(key: string, value: string): boolean {
|
||||
if (!isStorageAvailable()) return false;
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from localStorage
|
||||
* @param key - The storage key to remove
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
export function removeItem(key: string): boolean {
|
||||
if (!isStorageAvailable()) return false;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a JSON-parsed item from localStorage
|
||||
* @param key - The storage key
|
||||
* @returns The parsed value or null if not found/invalid
|
||||
*/
|
||||
export function getJSON<T>(key: string): T | null {
|
||||
const value = getItem(key);
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a JSON-stringified item in localStorage
|
||||
* @param key - The storage key
|
||||
* @param value - The value to stringify and store
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
export function setJSON<T>(key: string, value: T): boolean {
|
||||
try {
|
||||
return setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage module for named exports
|
||||
*/
|
||||
export const storage = {
|
||||
getItem,
|
||||
setItem,
|
||||
removeItem,
|
||||
getJSON,
|
||||
setJSON,
|
||||
isAvailable: isStorageAvailable,
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { AgentModel } from "@/store/app-store"
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { AgentModel } from '@/store/app-store';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current model supports extended thinking controls
|
||||
*/
|
||||
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||
export function modelSupportsThinking(_model?: AgentModel | string): boolean {
|
||||
// All Claude models support thinking
|
||||
return true;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||
*/
|
||||
export function getModelDisplayName(model: AgentModel | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
haiku: "Claude Haiku",
|
||||
sonnet: "Claude Sonnet",
|
||||
opus: "Claude Opus",
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function truncateDescription(description: string, maxLength = 50): string
|
||||
* This is important for cross-platform compatibility (Windows uses backslashes).
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/");
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
106
apps/ui/src/lib/workspace-config.ts
Normal file
106
apps/ui/src/lib/workspace-config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility functions for determining default workspace directories
|
||||
* Centralizes the logic for determining where projects should be created/opened
|
||||
*/
|
||||
|
||||
import { getHttpApiClient } from './http-api-client';
|
||||
import { getElectronAPI } from './electron';
|
||||
import { getItem, setItem } from './storage';
|
||||
import path from 'path';
|
||||
|
||||
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
|
||||
|
||||
/**
|
||||
* Gets the default Documents/Automaker directory path
|
||||
* @returns Promise resolving to Documents/Automaker path, or null if unavailable
|
||||
*/
|
||||
async function getDefaultDocumentsPath(): Promise<string | null> {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const documentsPath = await api.getPath('documents');
|
||||
return path.join(documentsPath, 'Automaker');
|
||||
} catch (error) {
|
||||
if (typeof window !== 'undefined' && window.console) {
|
||||
window.console.error('Failed to get documents path:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the default directory for project creation/opening
|
||||
* Priority order:
|
||||
* 1. ALLOWED_ROOT_DIRECTORY (if configured)
|
||||
* 2. Last used directory from localStorage (if ALLOWED_ROOT_DIRECTORY is not set)
|
||||
* 3. Documents/Automaker (if ALLOWED_ROOT_DIRECTORY is not set)
|
||||
* 4. DATA_DIR (if ALLOWED_ROOT_DIRECTORY is not set and Documents unavailable)
|
||||
* 5. null (no default)
|
||||
*
|
||||
* @returns Promise resolving to the default directory path, or null if none available
|
||||
*/
|
||||
export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const result = await httpClient.workspace.getConfig();
|
||||
|
||||
if (result.success) {
|
||||
// If ALLOWED_ROOT_DIRECTORY is configured, use it
|
||||
if (result.configured && result.workspaceDir) {
|
||||
return result.workspaceDir;
|
||||
}
|
||||
|
||||
// If ALLOWED_ROOT_DIRECTORY is not set, use priority:
|
||||
// 1. Last used directory
|
||||
// 2. Documents/Automaker
|
||||
// 3. DATA_DIR as fallback
|
||||
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
|
||||
|
||||
if (lastUsedDir) {
|
||||
return lastUsedDir;
|
||||
}
|
||||
|
||||
// Try to get Documents/Automaker
|
||||
const documentsPath = await getDefaultDocumentsPath();
|
||||
if (documentsPath) {
|
||||
return documentsPath;
|
||||
}
|
||||
|
||||
// Fallback to DATA_DIR if available
|
||||
if (result.defaultDir) {
|
||||
return result.defaultDir;
|
||||
}
|
||||
}
|
||||
|
||||
// If API call failed, still try last used dir and Documents
|
||||
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
|
||||
|
||||
if (lastUsedDir) {
|
||||
return lastUsedDir;
|
||||
}
|
||||
|
||||
const documentsPath = await getDefaultDocumentsPath();
|
||||
return documentsPath;
|
||||
} catch (error) {
|
||||
if (typeof window !== 'undefined' && window.console) {
|
||||
window.console.error('Failed to get default workspace directory:', error);
|
||||
}
|
||||
|
||||
// On error, try last used dir and Documents
|
||||
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
|
||||
|
||||
if (lastUsedDir) {
|
||||
return lastUsedDir;
|
||||
}
|
||||
|
||||
const documentsPath = await getDefaultDocumentsPath();
|
||||
return documentsPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the last used project directory to localStorage
|
||||
* @param path - The directory path to save
|
||||
*/
|
||||
export function saveLastProjectDirectory(path: string): void {
|
||||
setItem(LAST_PROJECT_DIR_KEY, path);
|
||||
}
|
||||
Reference in New Issue
Block a user