@leanspec/ui 0.2.5-dev.20251120070726 → 0.2.5-dev.20251120072524
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/packages/ui/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/ui/.next/app-path-routes-manifest.json +3 -0
- package/.next/standalone/packages/ui/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/ui/.next/prerender-manifest.json +3 -3
- package/.next/standalone/packages/ui/.next/routes-manifest.json +20 -0
- package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.html +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_not-found.rsc +3 -3
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route/app-paths-manifest.json +3 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route/build-manifest.json +11 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route/server-reference-manifest.json +4 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route.js +7 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route.js.map +5 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route.js.nft.json +1 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route_client-reference-manifest.js +2 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route/app-paths-manifest.json +3 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route/build-manifest.json +11 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route/server-reference-manifest.json +4 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route.js +7 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route.js.map +5 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route.js.nft.json +1 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route_client-reference-manifest.js +2 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route/app-paths-manifest.json +3 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route/build-manifest.json +11 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route/server-reference-manifest.json +4 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route.js +7 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route.js.map +5 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route.js.nft.json +1 -0
- package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route_client-reference-manifest.js +2 -0
- package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js +2 -1
- package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js +2 -1
- package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js +5 -2
- package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js +5 -2
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js +5 -2
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js +4 -2
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js +5 -2
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js +5 -2
- package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/specs/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/stats/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/stats/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/stats/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app-paths-manifest.json +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/730ea_ui__next-internal_server_app_api_local-projects_[id]_route_actions_664abe9c.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/730ea_ui__next-internal_server_app_api_local-projects_discover_route_actions_e6ec3fa7.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__4b3c3001._.js +21 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__4b77d48f._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__57791a48._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__60b6d106._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__a627840f._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__beb134c0._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c83721f3._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c8a20942._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__cd1fb0a2._.js +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__d293d769._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__dfa71dcd._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/{[root-of-the-server]__3971eae5._.js → [root-of-the-server]__e9ba3fa9._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__fad83967._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ecee3_js-yaml_dist_js-yaml_mjs_0775f118._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/node_modules__pnpm_731b55aa._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/packages_ui__next-internal_server_app_api_local-projects_route_actions_9142d5ad.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__34ab4950._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__5ca2e973._.js → [root-of-the-server]__a9d7fd42._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__41f5b5c0._.js → [root-of-the-server]__b3633d6e._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_000dd317._.js +1 -1
- package/.next/standalone/packages/ui/.next/server/pages/404.html +2 -2
- package/.next/standalone/packages/ui/.next/server/pages/500.html +2 -2
- package/.next/standalone/packages/ui/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/ui/.next/static/chunks/c0576ccd1437ac5e.css +1 -0
- package/.next/standalone/packages/ui/package.json +2 -1
- package/.next/standalone/packages/ui/src/app/api/local-projects/[id]/route.ts +117 -0
- package/.next/standalone/packages/ui/src/app/api/local-projects/discover/route.ts +41 -0
- package/.next/standalone/packages/ui/src/app/api/local-projects/route.ts +63 -0
- package/.next/standalone/packages/ui/src/components/project-switcher.tsx +230 -0
- package/.next/standalone/packages/ui/src/contexts/project-context.tsx +225 -0
- package/.next/standalone/packages/ui/src/lib/projects/index.ts +7 -0
- package/.next/standalone/packages/ui/src/lib/projects/registry.ts +393 -0
- package/.next/standalone/packages/ui/src/lib/projects/types.ts +37 -0
- package/.next/standalone/packages/ui/src/lib/specs/service.ts +13 -1
- package/.next/standalone/packages/ui/src/lib/specs/sources/multi-project-source.ts +258 -0
- package/.next/standalone/packages/ui/tsconfig.tsbuildinfo +1 -1
- package/.next/static/chunks/c0576ccd1437ac5e.css +1 -0
- package/package.json +2 -1
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__482b093a._.js +0 -21
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__6bca1621._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__87a3475a._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__9f0f4c0b._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c1c9f5f5._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__e2071b2e._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__299c81cc._.js +0 -3
- package/.next/standalone/packages/ui/.next/static/chunks/9a80c22382ddcfaf.css +0 -1
- package/.next/static/chunks/9a80c22382ddcfaf.css +0 -1
- /package/.next/standalone/packages/ui/.next/static/{C8sZuJV5DQETshOIgjsmH → fMRsihZys1Dhy9qQRwNrc}/_buildManifest.js +0 -0
- /package/.next/standalone/packages/ui/.next/static/{C8sZuJV5DQETshOIgjsmH → fMRsihZys1Dhy9qQRwNrc}/_clientMiddlewareManifest.json +0 -0
- /package/.next/standalone/packages/ui/.next/static/{C8sZuJV5DQETshOIgjsmH → fMRsihZys1Dhy9qQRwNrc}/_ssgManifest.js +0 -0
- /package/.next/static/{C8sZuJV5DQETshOIgjsmH → fMRsihZys1Dhy9qQRwNrc}/_buildManifest.js +0 -0
- /package/.next/static/{C8sZuJV5DQETshOIgjsmH → fMRsihZys1Dhy9qQRwNrc}/_clientMiddlewareManifest.json +0 -0
- /package/.next/static/{C8sZuJV5DQETshOIgjsmH → fMRsihZys1Dhy9qQRwNrc}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Context
|
|
3
|
+
* Manages the current project state for multi-project mode
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
9
|
+
import type { LocalProject } from '@/lib/projects/types';
|
|
10
|
+
|
|
11
|
+
interface ProjectContextType {
|
|
12
|
+
currentProject: LocalProject | null;
|
|
13
|
+
projects: LocalProject[];
|
|
14
|
+
recentProjects: LocalProject[];
|
|
15
|
+
favoriteProjects: LocalProject[];
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
error: string | null;
|
|
18
|
+
switchProject: (projectId: string) => Promise<void>;
|
|
19
|
+
addProject: (path: string, options?: { favorite?: boolean; color?: string }) => Promise<LocalProject>;
|
|
20
|
+
removeProject: (projectId: string) => Promise<void>;
|
|
21
|
+
toggleFavorite: (projectId: string) => Promise<void>;
|
|
22
|
+
updateProject: (projectId: string, updates: Partial<Pick<LocalProject, 'name' | 'color' | 'description'>>) => Promise<void>;
|
|
23
|
+
refreshProjects: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ProjectContext = createContext<ProjectContextType | null>(null);
|
|
27
|
+
|
|
28
|
+
export function useProject() {
|
|
29
|
+
const context = useContext(ProjectContext);
|
|
30
|
+
if (!context) {
|
|
31
|
+
throw new Error('useProject must be used within ProjectProvider');
|
|
32
|
+
}
|
|
33
|
+
return context;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ProjectProviderProps {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
initialProjectId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ProjectProvider({ children, initialProjectId }: ProjectProviderProps) {
|
|
42
|
+
const [currentProject, setCurrentProject] = useState<LocalProject | null>(null);
|
|
43
|
+
const [projects, setProjects] = useState<LocalProject[]>([]);
|
|
44
|
+
const [recentProjects, setRecentProjects] = useState<LocalProject[]>([]);
|
|
45
|
+
const [favoriteProjects, setFavoriteProjects] = useState<LocalProject[]>([]);
|
|
46
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
// Fetch all projects
|
|
50
|
+
const refreshProjects = useCallback(async () => {
|
|
51
|
+
try {
|
|
52
|
+
setIsLoading(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
|
|
55
|
+
const response = await fetch('/api/local-projects');
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error('Failed to fetch projects');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
setProjects(data.projects || []);
|
|
62
|
+
setRecentProjects(data.recentProjects || []);
|
|
63
|
+
setFavoriteProjects(data.favoriteProjects || []);
|
|
64
|
+
|
|
65
|
+
// Set current project if not already set
|
|
66
|
+
if (!currentProject && data.projects.length > 0) {
|
|
67
|
+
const projectToSet = initialProjectId
|
|
68
|
+
? data.projects.find((p: LocalProject) => p.id === initialProjectId)
|
|
69
|
+
: data.recentProjects[0] || data.projects[0];
|
|
70
|
+
|
|
71
|
+
if (projectToSet) {
|
|
72
|
+
setCurrentProject(projectToSet);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
console.error('Error fetching projects:', err);
|
|
77
|
+
setError(err.message || 'Failed to load projects');
|
|
78
|
+
} finally {
|
|
79
|
+
setIsLoading(false);
|
|
80
|
+
}
|
|
81
|
+
}, [currentProject, initialProjectId]);
|
|
82
|
+
|
|
83
|
+
// Switch to a different project
|
|
84
|
+
const switchProject = useCallback(async (projectId: string) => {
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(`/api/local-projects/${projectId}`);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error('Failed to fetch project');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = await response.json();
|
|
92
|
+
setCurrentProject(data.project);
|
|
93
|
+
|
|
94
|
+
// Refresh recent projects list
|
|
95
|
+
await refreshProjects();
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
console.error('Error switching project:', err);
|
|
98
|
+
setError(err.message || 'Failed to switch project');
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}, [refreshProjects]);
|
|
102
|
+
|
|
103
|
+
// Add a new project
|
|
104
|
+
const addProject = useCallback(async (
|
|
105
|
+
path: string,
|
|
106
|
+
options?: { favorite?: boolean; color?: string }
|
|
107
|
+
): Promise<LocalProject> => {
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetch('/api/local-projects', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ path, ...options }),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
const error = await response.json();
|
|
117
|
+
throw new Error(error.details || 'Failed to add project');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const data = await response.json();
|
|
121
|
+
await refreshProjects();
|
|
122
|
+
return data.project;
|
|
123
|
+
} catch (err: any) {
|
|
124
|
+
console.error('Error adding project:', err);
|
|
125
|
+
setError(err.message || 'Failed to add project');
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}, [refreshProjects]);
|
|
129
|
+
|
|
130
|
+
// Remove a project
|
|
131
|
+
const removeProject = useCallback(async (projectId: string) => {
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(`/api/local-projects/${projectId}`, {
|
|
134
|
+
method: 'DELETE',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error('Failed to remove project');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// If we removed the current project, switch to another
|
|
142
|
+
if (currentProject?.id === projectId) {
|
|
143
|
+
const otherProject = projects.find((p) => p.id !== projectId);
|
|
144
|
+
setCurrentProject(otherProject || null);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await refreshProjects();
|
|
148
|
+
} catch (err: any) {
|
|
149
|
+
console.error('Error removing project:', err);
|
|
150
|
+
setError(err.message || 'Failed to remove project');
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}, [currentProject, projects, refreshProjects]);
|
|
154
|
+
|
|
155
|
+
// Toggle favorite status
|
|
156
|
+
const toggleFavorite = useCallback(async (projectId: string) => {
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetch(`/api/local-projects/${projectId}`, {
|
|
159
|
+
method: 'PATCH',
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({ favorite: true }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
throw new Error('Failed to toggle favorite');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await refreshProjects();
|
|
169
|
+
} catch (err: any) {
|
|
170
|
+
console.error('Error toggling favorite:', err);
|
|
171
|
+
setError(err.message || 'Failed to toggle favorite');
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}, [refreshProjects]);
|
|
175
|
+
|
|
176
|
+
// Update project metadata
|
|
177
|
+
const updateProject = useCallback(async (
|
|
178
|
+
projectId: string,
|
|
179
|
+
updates: Partial<Pick<LocalProject, 'name' | 'color' | 'description'>>
|
|
180
|
+
) => {
|
|
181
|
+
try {
|
|
182
|
+
const response = await fetch(`/api/local-projects/${projectId}`, {
|
|
183
|
+
method: 'PATCH',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify(updates),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new Error('Failed to update project');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await refreshProjects();
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
console.error('Error updating project:', err);
|
|
195
|
+
setError(err.message || 'Failed to update project');
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
}, [refreshProjects]);
|
|
199
|
+
|
|
200
|
+
// Load projects on mount
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
refreshProjects();
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
const value: ProjectContextType = {
|
|
206
|
+
currentProject,
|
|
207
|
+
projects,
|
|
208
|
+
recentProjects,
|
|
209
|
+
favoriteProjects,
|
|
210
|
+
isLoading,
|
|
211
|
+
error,
|
|
212
|
+
switchProject,
|
|
213
|
+
addProject,
|
|
214
|
+
removeProject,
|
|
215
|
+
toggleFavorite,
|
|
216
|
+
updateProject,
|
|
217
|
+
refreshProjects,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<ProjectContext.Provider value={value}>
|
|
222
|
+
{children}
|
|
223
|
+
</ProjectContext.Provider>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project registry for local filesystem projects
|
|
3
|
+
* Manages discovery, storage, and retrieval of local LeanSpec projects
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'node:fs/promises';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
import type { LocalProject, ProjectsConfig, ProjectValidation } from './types';
|
|
12
|
+
|
|
13
|
+
const PROJECTS_CONFIG_FILE = path.join(homedir(), '.lean-spec', 'projects.yaml');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Project Registry - manages local filesystem projects
|
|
17
|
+
*/
|
|
18
|
+
export class ProjectRegistry {
|
|
19
|
+
private config: ProjectsConfig | null = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a unique ID for a project based on its path
|
|
23
|
+
*/
|
|
24
|
+
private generateProjectId(projectPath: string): string {
|
|
25
|
+
return createHash('sha256')
|
|
26
|
+
.update(projectPath)
|
|
27
|
+
.digest('hex')
|
|
28
|
+
.substring(0, 12);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load projects configuration from disk
|
|
33
|
+
*/
|
|
34
|
+
private async loadConfig(): Promise<ProjectsConfig> {
|
|
35
|
+
if (this.config) {
|
|
36
|
+
return this.config;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const configDir = path.dirname(PROJECTS_CONFIG_FILE);
|
|
41
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const fileContent = await fs.readFile(PROJECTS_CONFIG_FILE, 'utf-8');
|
|
44
|
+
const parsed = yaml.load(fileContent) as any;
|
|
45
|
+
|
|
46
|
+
// Convert date strings back to Date objects
|
|
47
|
+
const projects = (parsed.projects || []).map((p: any) => ({
|
|
48
|
+
...p,
|
|
49
|
+
lastAccessed: p.lastAccessed ? new Date(p.lastAccessed) : new Date(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
this.config = {
|
|
53
|
+
projects,
|
|
54
|
+
recentProjects: parsed.recentProjects || [],
|
|
55
|
+
};
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
if (error.code === 'ENOENT') {
|
|
58
|
+
// File doesn't exist, create empty config
|
|
59
|
+
this.config = {
|
|
60
|
+
projects: [],
|
|
61
|
+
recentProjects: [],
|
|
62
|
+
};
|
|
63
|
+
} else {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return this.config;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Save projects configuration to disk
|
|
73
|
+
*/
|
|
74
|
+
private async saveConfig(): Promise<void> {
|
|
75
|
+
if (!this.config) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const configDir = path.dirname(PROJECTS_CONFIG_FILE);
|
|
80
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
// Convert Date objects to ISO strings for serialization
|
|
83
|
+
const serializable = {
|
|
84
|
+
projects: this.config.projects.map((p) => ({
|
|
85
|
+
...p,
|
|
86
|
+
lastAccessed: p.lastAccessed.toISOString(),
|
|
87
|
+
})),
|
|
88
|
+
recentProjects: this.config.recentProjects,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const yamlContent = yaml.dump(serializable, {
|
|
92
|
+
indent: 2,
|
|
93
|
+
lineWidth: 100,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await fs.writeFile(PROJECTS_CONFIG_FILE, yamlContent, 'utf-8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a project path and extract metadata
|
|
101
|
+
*/
|
|
102
|
+
async validateProject(projectPath: string): Promise<ProjectValidation> {
|
|
103
|
+
try {
|
|
104
|
+
const normalizedPath = path.resolve(projectPath);
|
|
105
|
+
|
|
106
|
+
// Check if path exists
|
|
107
|
+
const stats = await fs.stat(normalizedPath);
|
|
108
|
+
if (!stats.isDirectory()) {
|
|
109
|
+
return {
|
|
110
|
+
isValid: false,
|
|
111
|
+
path: normalizedPath,
|
|
112
|
+
error: 'Path is not a directory',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Look for .lean-spec directory or specs directory
|
|
117
|
+
let specsDir: string | undefined;
|
|
118
|
+
const leanSpecDir = path.join(normalizedPath, '.lean-spec');
|
|
119
|
+
const specsOnlyDir = path.join(normalizedPath, 'specs');
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await fs.access(leanSpecDir);
|
|
123
|
+
// If .lean-spec exists, check for specs inside or alongside
|
|
124
|
+
const specsInLeanSpec = path.join(leanSpecDir, '../specs');
|
|
125
|
+
try {
|
|
126
|
+
await fs.access(specsInLeanSpec);
|
|
127
|
+
specsDir = specsInLeanSpec;
|
|
128
|
+
} catch {
|
|
129
|
+
specsDir = specsOnlyDir;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// No .lean-spec, check for specs directory
|
|
133
|
+
try {
|
|
134
|
+
await fs.access(specsOnlyDir);
|
|
135
|
+
specsDir = specsOnlyDir;
|
|
136
|
+
} catch {
|
|
137
|
+
return {
|
|
138
|
+
isValid: false,
|
|
139
|
+
path: normalizedPath,
|
|
140
|
+
error: 'No .lean-spec directory or specs directory found',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Try to read project name and description from various sources
|
|
146
|
+
let name = path.basename(normalizedPath);
|
|
147
|
+
let description: string | undefined;
|
|
148
|
+
|
|
149
|
+
// Try leanspec.yaml
|
|
150
|
+
try {
|
|
151
|
+
const leanspecYaml = path.join(normalizedPath, 'leanspec.yaml');
|
|
152
|
+
const content = await fs.readFile(leanspecYaml, 'utf-8');
|
|
153
|
+
const config = yaml.load(content) as any;
|
|
154
|
+
if (config.name) name = config.name;
|
|
155
|
+
if (config.description) description = config.description;
|
|
156
|
+
} catch {
|
|
157
|
+
// Try package.json
|
|
158
|
+
try {
|
|
159
|
+
const packageJson = path.join(normalizedPath, 'package.json');
|
|
160
|
+
const content = await fs.readFile(packageJson, 'utf-8');
|
|
161
|
+
const pkg = JSON.parse(content);
|
|
162
|
+
if (pkg.name) name = pkg.name;
|
|
163
|
+
if (pkg.description) description = pkg.description;
|
|
164
|
+
} catch {
|
|
165
|
+
// Use directory name as fallback
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
isValid: true,
|
|
171
|
+
path: normalizedPath,
|
|
172
|
+
specsDir: path.resolve(specsDir),
|
|
173
|
+
name,
|
|
174
|
+
description,
|
|
175
|
+
};
|
|
176
|
+
} catch (error: any) {
|
|
177
|
+
return {
|
|
178
|
+
isValid: false,
|
|
179
|
+
path: projectPath,
|
|
180
|
+
error: error.message || 'Unknown error',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Add a project to the registry
|
|
187
|
+
*/
|
|
188
|
+
async addProject(projectPath: string, options?: { favorite?: boolean; color?: string }): Promise<LocalProject> {
|
|
189
|
+
const validation = await this.validateProject(projectPath);
|
|
190
|
+
|
|
191
|
+
if (!validation.isValid) {
|
|
192
|
+
throw new Error(`Invalid project: ${validation.error}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const config = await this.loadConfig();
|
|
196
|
+
const projectId = this.generateProjectId(validation.path);
|
|
197
|
+
|
|
198
|
+
// Check if project already exists
|
|
199
|
+
const existingIndex = config.projects.findIndex((p) => p.id === projectId);
|
|
200
|
+
|
|
201
|
+
const project: LocalProject = {
|
|
202
|
+
id: projectId,
|
|
203
|
+
name: validation.name!,
|
|
204
|
+
path: validation.path,
|
|
205
|
+
specsDir: validation.specsDir!,
|
|
206
|
+
lastAccessed: new Date(),
|
|
207
|
+
favorite: options?.favorite || false,
|
|
208
|
+
color: options?.color,
|
|
209
|
+
description: validation.description,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (existingIndex >= 0) {
|
|
213
|
+
// Update existing project
|
|
214
|
+
config.projects[existingIndex] = project;
|
|
215
|
+
} else {
|
|
216
|
+
// Add new project
|
|
217
|
+
config.projects.push(project);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Update recent projects
|
|
221
|
+
this.updateRecentProjects(config, projectId);
|
|
222
|
+
|
|
223
|
+
await this.saveConfig();
|
|
224
|
+
return project;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Remove a project from the registry
|
|
229
|
+
*/
|
|
230
|
+
async removeProject(projectId: string): Promise<void> {
|
|
231
|
+
const config = await this.loadConfig();
|
|
232
|
+
|
|
233
|
+
config.projects = config.projects.filter((p) => p.id !== projectId);
|
|
234
|
+
config.recentProjects = config.recentProjects.filter((id) => id !== projectId);
|
|
235
|
+
|
|
236
|
+
await this.saveConfig();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get a project by ID
|
|
241
|
+
*/
|
|
242
|
+
async getProject(projectId: string): Promise<LocalProject | null> {
|
|
243
|
+
const config = await this.loadConfig();
|
|
244
|
+
return config.projects.find((p) => p.id === projectId) || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get all projects
|
|
249
|
+
*/
|
|
250
|
+
async getProjects(): Promise<LocalProject[]> {
|
|
251
|
+
const config = await this.loadConfig();
|
|
252
|
+
return config.projects;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Update project lastAccessed timestamp and add to recent projects
|
|
257
|
+
*/
|
|
258
|
+
async touchProject(projectId: string): Promise<void> {
|
|
259
|
+
const config = await this.loadConfig();
|
|
260
|
+
|
|
261
|
+
const project = config.projects.find((p) => p.id === projectId);
|
|
262
|
+
if (!project) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
project.lastAccessed = new Date();
|
|
267
|
+
this.updateRecentProjects(config, projectId);
|
|
268
|
+
|
|
269
|
+
await this.saveConfig();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Toggle favorite status
|
|
274
|
+
*/
|
|
275
|
+
async toggleFavorite(projectId: string): Promise<boolean> {
|
|
276
|
+
const config = await this.loadConfig();
|
|
277
|
+
|
|
278
|
+
const project = config.projects.find((p) => p.id === projectId);
|
|
279
|
+
if (!project) {
|
|
280
|
+
throw new Error('Project not found');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
project.favorite = !project.favorite;
|
|
284
|
+
await this.saveConfig();
|
|
285
|
+
|
|
286
|
+
return project.favorite;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Update project metadata
|
|
291
|
+
*/
|
|
292
|
+
async updateProject(projectId: string, updates: Partial<Pick<LocalProject, 'name' | 'color' | 'description'>>): Promise<LocalProject> {
|
|
293
|
+
const config = await this.loadConfig();
|
|
294
|
+
|
|
295
|
+
const project = config.projects.find((p) => p.id === projectId);
|
|
296
|
+
if (!project) {
|
|
297
|
+
throw new Error('Project not found');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
Object.assign(project, updates);
|
|
301
|
+
await this.saveConfig();
|
|
302
|
+
|
|
303
|
+
return project;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Discover projects in a directory tree
|
|
308
|
+
*/
|
|
309
|
+
async discoverProjects(rootDir: string, maxDepth: number = 3): Promise<ProjectValidation[]> {
|
|
310
|
+
const discovered: ProjectValidation[] = [];
|
|
311
|
+
|
|
312
|
+
async function scan(dir: string, depth: number) {
|
|
313
|
+
if (depth > maxDepth) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
319
|
+
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
if (!entry.isDirectory()) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Skip common ignore patterns
|
|
326
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const fullPath = path.join(dir, entry.name);
|
|
331
|
+
|
|
332
|
+
// Check if this directory is a LeanSpec project
|
|
333
|
+
const validation = await projectRegistry.validateProject(fullPath);
|
|
334
|
+
if (validation.isValid) {
|
|
335
|
+
discovered.push(validation);
|
|
336
|
+
} else {
|
|
337
|
+
// Recurse into subdirectories
|
|
338
|
+
await scan(fullPath, depth + 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
// Skip directories we can't read
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await scan(rootDir, 0);
|
|
347
|
+
return discovered;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get recent projects
|
|
352
|
+
*/
|
|
353
|
+
async getRecentProjects(limit: number = 10): Promise<LocalProject[]> {
|
|
354
|
+
const config = await this.loadConfig();
|
|
355
|
+
|
|
356
|
+
return config.recentProjects
|
|
357
|
+
.slice(0, limit)
|
|
358
|
+
.map((id) => config.projects.find((p) => p.id === id))
|
|
359
|
+
.filter((p): p is LocalProject => p !== undefined);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get favorite projects
|
|
364
|
+
*/
|
|
365
|
+
async getFavoriteProjects(): Promise<LocalProject[]> {
|
|
366
|
+
const config = await this.loadConfig();
|
|
367
|
+
return config.projects.filter((p) => p.favorite);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Update recent projects list
|
|
372
|
+
*/
|
|
373
|
+
private updateRecentProjects(config: ProjectsConfig, projectId: string): void {
|
|
374
|
+
// Remove if already in list
|
|
375
|
+
config.recentProjects = config.recentProjects.filter((id) => id !== projectId);
|
|
376
|
+
|
|
377
|
+
// Add to front
|
|
378
|
+
config.recentProjects.unshift(projectId);
|
|
379
|
+
|
|
380
|
+
// Keep only last 10
|
|
381
|
+
config.recentProjects = config.recentProjects.slice(0, 10);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Invalidate cached config
|
|
386
|
+
*/
|
|
387
|
+
invalidateCache(): void {
|
|
388
|
+
this.config = null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Singleton instance
|
|
393
|
+
export const projectRegistry = new ProjectRegistry();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for local multi-project support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Local project metadata
|
|
7
|
+
*/
|
|
8
|
+
export interface LocalProject {
|
|
9
|
+
id: string; // Unique identifier (hash of path)
|
|
10
|
+
name: string; // Display name (from config or folder name)
|
|
11
|
+
path: string; // Absolute path to project root
|
|
12
|
+
specsDir: string; // Path to specs/ directory
|
|
13
|
+
lastAccessed: Date; // For sorting recent projects
|
|
14
|
+
favorite: boolean; // User can pin favorites
|
|
15
|
+
color?: string; // Optional color coding
|
|
16
|
+
description?: string; // From project README or config
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Projects configuration file structure
|
|
21
|
+
*/
|
|
22
|
+
export interface ProjectsConfig {
|
|
23
|
+
projects: LocalProject[];
|
|
24
|
+
recentProjects: string[]; // Project IDs in order (max 10)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Project validation result
|
|
29
|
+
*/
|
|
30
|
+
export interface ProjectValidation {
|
|
31
|
+
isValid: boolean;
|
|
32
|
+
path: string;
|
|
33
|
+
specsDir?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { FilesystemSource } from './sources/filesystem-source';
|
|
7
|
+
import { MultiProjectFilesystemSource } from './sources/multi-project-source';
|
|
7
8
|
import type { SpecSource } from './types';
|
|
8
9
|
import type { Spec } from '../db/schema';
|
|
9
10
|
|
|
@@ -14,10 +15,11 @@ let DatabaseSourceCtor: DatabaseSourceConstructor | null = null;
|
|
|
14
15
|
/**
|
|
15
16
|
* Service modes
|
|
16
17
|
* - filesystem: Read from local filesystem only (LeanSpec's own specs)
|
|
18
|
+
* - multi-project: Read from multiple local filesystem projects
|
|
17
19
|
* - database: Read from database only (external repos)
|
|
18
20
|
* - both: Support both modes (route based on projectId)
|
|
19
21
|
*/
|
|
20
|
-
type SpecsMode = 'filesystem' | 'database' | 'both';
|
|
22
|
+
type SpecsMode = 'filesystem' | 'multi-project' | 'database' | 'both';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Unified specs service
|
|
@@ -25,6 +27,7 @@ type SpecsMode = 'filesystem' | 'database' | 'both';
|
|
|
25
27
|
*/
|
|
26
28
|
export class SpecsService {
|
|
27
29
|
private filesystemSource?: FilesystemSource;
|
|
30
|
+
private multiProjectSource?: MultiProjectFilesystemSource;
|
|
28
31
|
private databaseSource?: SpecSource;
|
|
29
32
|
private mode: SpecsMode;
|
|
30
33
|
|
|
@@ -36,6 +39,10 @@ export class SpecsService {
|
|
|
36
39
|
this.filesystemSource = new FilesystemSource(specsDir);
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
if (this.mode === 'multi-project') {
|
|
43
|
+
this.multiProjectSource = new MultiProjectFilesystemSource();
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
// Don't instantiate database source here - do it lazily
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -102,6 +109,11 @@ export class SpecsService {
|
|
|
102
109
|
* Get the appropriate source based on projectId and mode
|
|
103
110
|
*/
|
|
104
111
|
private async getSource(projectId?: string): Promise<SpecSource> {
|
|
112
|
+
// Multi-project mode: use multi-project source
|
|
113
|
+
if (this.mode === 'multi-project' && this.multiProjectSource) {
|
|
114
|
+
return this.multiProjectSource;
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
// If projectId provided, use database (external repo)
|
|
106
118
|
if (projectId && (this.mode === 'database' || this.mode === 'both')) {
|
|
107
119
|
return await this.getDatabaseSource();
|