mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge branch 'main' into feature/shared-packages
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
@@ -21,6 +20,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getJSON, setJSON } from "@/lib/storage";
|
||||
import {
|
||||
getDefaultWorkspaceDirectory,
|
||||
saveLastProjectDirectory,
|
||||
} from "@/lib/workspace-config";
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
@@ -50,38 +54,22 @@ const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
|
||||
const MAX_RECENT_FOLDERS = 5;
|
||||
|
||||
function getRecentFolders(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return getJSON<string[]>(RECENT_FOLDERS_KEY) ?? [];
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = recent.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
const recent = getRecentFolders();
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = recent.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||
setJSON(RECENT_FOLDERS_KEY, updated);
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
const updated = recent.filter((p) => p !== path);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const recent = getRecentFolders();
|
||||
const updated = recent.filter((p) => p !== path);
|
||||
setJSON(RECENT_FOLDERS_KEY, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function FileBrowserDialog({
|
||||
@@ -110,17 +98,16 @@ export function FileBrowserDialog({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = removeRecentFolder(path);
|
||||
setRecentFolders(updated);
|
||||
}, []);
|
||||
const handleRemoveRecent = useCallback(
|
||||
(e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = removeRecentFolder(path);
|
||||
setRecentFolders(updated);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectRecent = useCallback((path: string) => {
|
||||
browseDirectory(path);
|
||||
}, []);
|
||||
|
||||
const browseDirectory = async (dirPath?: string) => {
|
||||
const browseDirectory = useCallback(async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setWarning("");
|
||||
@@ -155,7 +142,14 @@ export function FileBrowserDialog({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectRecent = useCallback(
|
||||
(path: string) => {
|
||||
browseDirectory(path);
|
||||
},
|
||||
[browseDirectory]
|
||||
);
|
||||
|
||||
// Reset current path when dialog closes
|
||||
useEffect(() => {
|
||||
@@ -169,12 +163,46 @@ export function FileBrowserDialog({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load initial path or home directory when dialog opens
|
||||
// Load initial path or workspace directory when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && !currentPath) {
|
||||
browseDirectory(initialPath);
|
||||
// Priority order:
|
||||
// 1. Last selected directory from this file browser (from localStorage)
|
||||
// 2. initialPath prop (from parent component)
|
||||
// 3. Default workspace directory
|
||||
// 4. Home directory
|
||||
const loadInitialPath = async () => {
|
||||
try {
|
||||
// First, check for last selected directory from getDefaultWorkspaceDirectory
|
||||
// which already implements the priority: last used > Documents/Automaker > DATA_DIR
|
||||
const defaultDir = await getDefaultWorkspaceDirectory();
|
||||
|
||||
// If we have a default directory, use it (unless initialPath is explicitly provided and different)
|
||||
const pathToUse = initialPath || defaultDir;
|
||||
|
||||
if (pathToUse) {
|
||||
// Pre-fill the path input immediately
|
||||
setPathInput(pathToUse);
|
||||
// Then browse to that directory
|
||||
browseDirectory(pathToUse);
|
||||
} else {
|
||||
// No default directory, browse home directory
|
||||
browseDirectory();
|
||||
}
|
||||
} catch (err) {
|
||||
// If config fetch fails, try initialPath or fall back to home directory
|
||||
if (initialPath) {
|
||||
setPathInput(initialPath);
|
||||
browseDirectory(initialPath);
|
||||
} else {
|
||||
browseDirectory();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialPath();
|
||||
}
|
||||
}, [open, initialPath]);
|
||||
}, [open, initialPath, currentPath, browseDirectory]);
|
||||
|
||||
const handleSelectDirectory = (dir: DirectoryEntry) => {
|
||||
browseDirectory(dir.path);
|
||||
@@ -211,6 +239,8 @@ export function FileBrowserDialog({
|
||||
const handleSelect = useCallback(() => {
|
||||
if (currentPath) {
|
||||
addRecentFolder(currentPath);
|
||||
// Save to last project directory so it's used as default next time
|
||||
saveLastProjectDirectory(currentPath);
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
@@ -296,7 +326,9 @@ export function FileBrowserDialog({
|
||||
title={folder}
|
||||
>
|
||||
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{getFolderName(folder)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => handleRemoveRecent(e, folder)}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||
@@ -417,11 +449,20 @@ export function FileBrowserDialog({
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading} title="Select current folder (Cmd+Enter / Ctrl+Enter)">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSelect}
|
||||
disabled={!currentPath || loading}
|
||||
title="Select current folder (Cmd+Enter / Ctrl+Enter)"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
|
||||
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "⌘" : "Ctrl"}+↵
|
||||
{typeof navigator !== "undefined" &&
|
||||
navigator.platform?.includes("Mac")
|
||||
? "⌘"
|
||||
: "Ctrl"}
|
||||
+↵
|
||||
</kbd>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -26,11 +25,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||
|
||||
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
|
||||
import {
|
||||
getDefaultWorkspaceDirectory,
|
||||
saveLastProjectDirectory,
|
||||
} from "@/lib/workspace-config";
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
@@ -81,25 +81,15 @@ export function NewProjectModal({
|
||||
// Fetch workspace directory when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// First, check localStorage for last used directory
|
||||
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
|
||||
if (lastUsedDir) {
|
||||
setWorkspaceDir(lastUsedDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to server config if no saved directory
|
||||
setIsLoadingWorkspace(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.workspace
|
||||
.getConfig()
|
||||
.then((result) => {
|
||||
if (result.success && result.workspaceDir) {
|
||||
setWorkspaceDir(result.workspaceDir);
|
||||
getDefaultWorkspaceDirectory()
|
||||
.then((defaultDir) => {
|
||||
if (defaultDir) {
|
||||
setWorkspaceDir(defaultDir);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get workspace config:", error);
|
||||
console.error("Failed to get default workspace directory:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingWorkspace(false);
|
||||
@@ -211,7 +201,7 @@ export function NewProjectModal({
|
||||
if (selectedPath) {
|
||||
setWorkspaceDir(selectedPath);
|
||||
// Save to localStorage for next time
|
||||
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
|
||||
saveLastProjectDirectory(selectedPath);
|
||||
// Clear any workspace error when a valid directory is selected
|
||||
if (errors.workspaceDir) {
|
||||
setErrors((prev) => ({ ...prev, workspaceDir: false }));
|
||||
@@ -296,9 +286,7 @@ export function NewProjectModal({
|
||||
{projectPath || workspaceDir}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-500">No workspace configured</span>
|
||||
)}
|
||||
) : null}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -191,7 +191,9 @@ export function WorktreeTab({
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title="Click to preview main"
|
||||
title={`Click to preview ${worktree.branch}`}
|
||||
aria-label={worktree.branch}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { cn, pathsEqual } from "@/lib/utils";
|
||||
import { getItem, setItem } from "@/lib/storage";
|
||||
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||
import {
|
||||
useWorktrees,
|
||||
@@ -91,13 +92,12 @@ export function WorktreePanel({
|
||||
|
||||
// Collapse state with localStorage persistence
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem(WORKTREE_PANEL_COLLAPSED_KEY);
|
||||
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
|
||||
return saved === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
|
||||
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -20,6 +19,10 @@ import { Markdown } from "@/components/ui/markdown";
|
||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
getDefaultWorkspaceDirectory,
|
||||
saveLastProjectDirectory,
|
||||
} from "@/lib/workspace-config";
|
||||
|
||||
interface InterviewMessage {
|
||||
id: string;
|
||||
@@ -65,8 +68,7 @@ const INTERVIEW_QUESTIONS = [
|
||||
];
|
||||
|
||||
export function InterviewView() {
|
||||
const { addProject, setCurrentProject, setAppSpec } =
|
||||
useAppStore();
|
||||
const { addProject, setCurrentProject, setAppSpec } = useAppStore();
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
const navigate = useNavigate();
|
||||
const [input, setInput] = useState("");
|
||||
@@ -89,6 +91,35 @@ export function InterviewView() {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Default parent directory using workspace config utility
|
||||
useEffect(() => {
|
||||
if (projectPath) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadWorkspaceDir = async () => {
|
||||
try {
|
||||
const defaultDir = await getDefaultWorkspaceDirectory();
|
||||
|
||||
if (!isMounted || projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultDir) {
|
||||
setProjectPath(defaultDir);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load default workspace directory:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadWorkspaceDir();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
// Initialize with first question
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
@@ -295,10 +326,12 @@ export function InterviewView() {
|
||||
title: "Select Base Directory",
|
||||
description:
|
||||
"Choose the parent directory where your new project will be created",
|
||||
initialPath: projectPath || undefined,
|
||||
});
|
||||
|
||||
if (selectedPath) {
|
||||
setProjectPath(selectedPath);
|
||||
saveLastProjectDirectory(selectedPath);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -308,6 +341,7 @@ export function InterviewView() {
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
saveLastProjectDirectory(projectPath);
|
||||
const api = getElectronAPI();
|
||||
// Use platform-specific path separator
|
||||
const pathSep =
|
||||
@@ -423,8 +457,8 @@ export function InterviewView() {
|
||||
index < currentQuestionIndex
|
||||
? "bg-green-500"
|
||||
: index === currentQuestionIndex
|
||||
? "bg-primary"
|
||||
: "bg-zinc-700"
|
||||
? "bg-primary"
|
||||
: "bg-zinc-700"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -239,6 +239,24 @@ export function WelcomeView() {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Validate that parent directory exists
|
||||
const parentExists = await api.exists(parentDir);
|
||||
if (!parentExists) {
|
||||
toast.error("Parent directory does not exist", {
|
||||
description: `Cannot create project in non-existent directory: ${parentDir}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify parent is actually a directory
|
||||
const parentStat = await api.stat(parentDir);
|
||||
if (parentStat && !parentStat.isDirectory) {
|
||||
toast.error("Parent path is not a directory", {
|
||||
description: `${parentDir} is not a directory`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create project directory
|
||||
const mkdirResult = await api.mkdir(projectPath);
|
||||
if (!mkdirResult.success) {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getItem, removeItem } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* State returned by useSettingsMigration hook
|
||||
@@ -122,7 +123,7 @@ export function useSettingsMigration(): MigrationState {
|
||||
}
|
||||
|
||||
// Check if we have localStorage data to migrate
|
||||
const automakerStorage = localStorage.getItem("automaker-storage");
|
||||
const automakerStorage = getItem("automaker-storage");
|
||||
if (!automakerStorage) {
|
||||
console.log(
|
||||
"[Settings Migration] No localStorage data to migrate"
|
||||
@@ -136,7 +137,7 @@ export function useSettingsMigration(): MigrationState {
|
||||
// Collect all localStorage data
|
||||
const localStorageData: Record<string, string> = {};
|
||||
for (const key of LOCALSTORAGE_KEYS) {
|
||||
const value = localStorage.getItem(key);
|
||||
const value = getItem(key);
|
||||
if (value) {
|
||||
localStorageData[key] = value;
|
||||
}
|
||||
@@ -154,7 +155,7 @@ export function useSettingsMigration(): MigrationState {
|
||||
|
||||
// Clear old localStorage keys (but keep automaker-storage for Zustand)
|
||||
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
|
||||
localStorage.removeItem(key);
|
||||
removeItem(key);
|
||||
}
|
||||
|
||||
setState({ checked: true, migrated: true, error: null });
|
||||
@@ -203,7 +204,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const automakerStorage = localStorage.getItem("automaker-storage");
|
||||
const automakerStorage = getItem("automaker-storage");
|
||||
|
||||
if (!automakerStorage) {
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Type definitions for Electron IPC API
|
||||
import type { SessionListItem, Message } from "@/types/electron";
|
||||
import { getJSON, setJSON, removeItem } from "./storage";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
@@ -2667,28 +2668,22 @@ export interface TrashedProject extends Project {
|
||||
}
|
||||
|
||||
export const getStoredProjects = (): Project[] => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.PROJECTS);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
return getJSON<Project[]>(STORAGE_KEYS.PROJECTS) ?? [];
|
||||
};
|
||||
|
||||
export const saveProjects = (projects: Project[]): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEYS.PROJECTS, JSON.stringify(projects));
|
||||
setJSON(STORAGE_KEYS.PROJECTS, projects);
|
||||
};
|
||||
|
||||
export const getCurrentProject = (): Project | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_PROJECT);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
return getJSON<Project>(STORAGE_KEYS.CURRENT_PROJECT);
|
||||
};
|
||||
|
||||
export const setCurrentProject = (project: Project | null): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (project) {
|
||||
localStorage.setItem(STORAGE_KEYS.CURRENT_PROJECT, JSON.stringify(project));
|
||||
setJSON(STORAGE_KEYS.CURRENT_PROJECT, project);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.CURRENT_PROJECT);
|
||||
removeItem(STORAGE_KEYS.CURRENT_PROJECT);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2709,12 +2704,9 @@ export const removeProject = (projectId: string): void => {
|
||||
};
|
||||
|
||||
export const getStoredTrashedProjects = (): TrashedProject[] => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.TRASHED_PROJECTS);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
return getJSON<TrashedProject[]>(STORAGE_KEYS.TRASHED_PROJECTS) ?? [];
|
||||
};
|
||||
|
||||
export const saveTrashedProjects = (projects: TrashedProject[]): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEYS.TRASHED_PROJECTS, JSON.stringify(projects));
|
||||
setJSON(STORAGE_KEYS.TRASHED_PROJECTS, projects);
|
||||
};
|
||||
|
||||
@@ -766,6 +766,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
success: boolean;
|
||||
configured: boolean;
|
||||
workspaceDir?: string;
|
||||
defaultDir?: string | null;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/config"),
|
||||
|
||||
|
||||
@@ -48,6 +48,34 @@ export async function initializeProject(
|
||||
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) {
|
||||
|
||||
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,
|
||||
};
|
||||
107
apps/ui/src/lib/workspace-config.ts
Normal file
107
apps/ui/src/lib/workspace-config.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Utility functions for determining default workspace directories
|
||||
* Centralizes the logic for determining where projects should be created/opened
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
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);
|
||||
}
|
||||
@@ -116,7 +116,9 @@ async function startStaticServer(): Promise<void> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
staticServer!.listen(STATIC_PORT, () => {
|
||||
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||
console.log(
|
||||
`[Electron] Static server running at http://localhost:${STATIC_PORT}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
staticServer!.on("error", reject);
|
||||
@@ -135,7 +137,10 @@ async function startServer(): Promise<void> {
|
||||
command = "node";
|
||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
||||
|
||||
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
|
||||
const serverNodeModules = path.join(
|
||||
__dirname,
|
||||
"../../server/node_modules/tsx"
|
||||
);
|
||||
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||
|
||||
let tsxCliPath: string;
|
||||
@@ -170,23 +175,16 @@ async function startServer(): Promise<void> {
|
||||
? path.join(process.resourcesPath, "server", "node_modules")
|
||||
: path.join(__dirname, "../../server/node_modules");
|
||||
|
||||
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
|
||||
|
||||
if (!fs.existsSync(defaultWorkspaceDir)) {
|
||||
try {
|
||||
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
|
||||
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to create workspace directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath("userData"),
|
||||
NODE_PATH: serverNodeModules,
|
||||
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
|
||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||
// If not set, server will allow access to all paths
|
||||
...(process.env.ALLOWED_ROOT_DIRECTORY && {
|
||||
ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY,
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("[Electron] Starting backend server...");
|
||||
@@ -324,7 +322,10 @@ app.whenReady().then(async () => {
|
||||
try {
|
||||
app.dock.setIcon(iconPath);
|
||||
} catch (error) {
|
||||
console.warn("[Electron] Failed to set dock icon:", (error as Error).message);
|
||||
console.warn(
|
||||
"[Electron] Failed to set dock icon:",
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,9 +427,12 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
|
||||
});
|
||||
|
||||
// App info
|
||||
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
ipcMain.handle(
|
||||
"app:getPath",
|
||||
async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("app:getVersion", async () => {
|
||||
return app.getVersion();
|
||||
|
||||
Reference in New Issue
Block a user