Skip to content

Commit adddf56

Browse files
author
Konstantin Taletskiy
committed
feat: add "Create Project" dialog for initializing new project types
Backend: new GET /creatable-types (cached) and POST /create endpoints that introspect the projspec registry and call Project.create(). Frontend: searchable type picker dialog launched via a "+" button in the sidebar header, the command palette, or a directory context menu. Already-detected types are filtered out. Success triggers a re-scan and a toast notification. Requires projspec >0.2.0 for the updated Project.create() API. Bumps jupyter-projspec version to 0.3.0. Made-with: Cursor
1 parent 48c11dc commit adddf56

13 files changed

Lines changed: 1017 additions & 30 deletions

File tree

jupyter_projspec/routes.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,47 @@ class ArtifactLookupError(ValueError):
8888
"""Raised when a spec type or artifact name cannot be found in projspec."""
8989

9090

91+
class TypeNotCreatableError(ValueError):
92+
"""Raised when a requested project type does not support creation."""
93+
94+
95+
# Cached list of project types that support creation.
96+
# Computed once on first access since the projspec registry is stable at runtime.
97+
_creatable_types_cache: list[dict] | None = None
98+
99+
100+
def _get_creatable_types() -> list[dict]:
101+
"""Return the list of project types that have implemented _create.
102+
103+
Results are cached at module level since the registry does not change
104+
during the lifetime of the server process.
105+
"""
106+
global _creatable_types_cache
107+
if _creatable_types_cache is not None:
108+
return _creatable_types_cache
109+
110+
from projspec.proj.base import ProjectSpec, registry
111+
112+
result = []
113+
for name, cls in sorted(registry.items()):
114+
if not issubclass(cls, ProjectSpec):
115+
continue
116+
if cls._create is ProjectSpec._create:
117+
continue
118+
result.append({
119+
"name": name,
120+
"doc": (cls.__doc__ or "").strip(),
121+
"link": getattr(cls, "spec_doc", ""),
122+
})
123+
_creatable_types_cache = result
124+
return _creatable_types_cache
125+
126+
127+
def _is_type_creatable(type_name: str) -> bool:
128+
"""Check whether a given type name is in the creatable types list."""
129+
return any(t["name"] == type_name for t in _get_creatable_types())
130+
131+
91132
def resolve_path(contents_manager: ContentsManager, relative_path: str) -> str:
92133
"""Validate and resolve a relative path to an absolute path within the server root.
93134
@@ -508,16 +549,127 @@ def get(self):
508549
self.finish(json.dumps({"error": "Error scanning directory"}))
509550

510551

552+
class CreatableTypesRouteHandler(APIHandler):
553+
"""Handler that returns the list of project types supporting creation.
554+
555+
The result is computed once from projspec's registry and cached for the
556+
lifetime of the server process.
557+
"""
558+
559+
@tornado.web.authenticated
560+
def get(self):
561+
"""Return all creatable project types.
562+
563+
Returns:
564+
JSON with "types" key containing a list of
565+
{name, doc, link} objects.
566+
"""
567+
try:
568+
types = _get_creatable_types()
569+
self.finish(json.dumps({"types": types}))
570+
except Exception as e:
571+
logger.error("Error fetching creatable types: %s", e, exc_info=True)
572+
self.set_status(500)
573+
self.finish(json.dumps({"error": "Failed to retrieve creatable types"}))
574+
575+
576+
class CreateRouteHandler(APIHandler):
577+
"""Handler for creating new project types in a directory via projspec."""
578+
579+
@tornado.web.authenticated
580+
async def post(self):
581+
"""Create a new project type in the specified directory.
582+
583+
Request Body:
584+
path: Relative path from server root (empty string for root)
585+
type_name: The projspec type to create (e.g., "git_repo", "pixi")
586+
587+
Returns:
588+
JSON with "created_files" listing the new files.
589+
"""
590+
try:
591+
data = self.get_json_body()
592+
except (json.JSONDecodeError, ValueError):
593+
self.set_status(400)
594+
self.finish(json.dumps({"error": "Invalid or missing JSON body"}))
595+
return
596+
597+
if not isinstance(data, dict):
598+
self.set_status(400)
599+
self.finish(json.dumps({"error": "Request body must be a JSON object"}))
600+
return
601+
602+
type_name = data.get("type_name")
603+
if not isinstance(type_name, str) or not type_name.strip():
604+
self.set_status(400)
605+
self.finish(json.dumps({"error": "Missing or invalid required field: type_name"}))
606+
return
607+
608+
path = data.get("path", "")
609+
if not isinstance(path, str):
610+
self.set_status(400)
611+
self.finish(json.dumps({"error": "Field 'path' must be a string"}))
612+
return
613+
614+
try:
615+
result = await tornado.ioloop.IOLoop.current().run_in_executor(
616+
_executor, self._run_create, path, type_name.strip()
617+
)
618+
self.finish(json.dumps(result))
619+
except PathSecurityError as e:
620+
self.set_status(403)
621+
self.finish(json.dumps({"error": str(e)}))
622+
except PathNotFoundError as e:
623+
self.set_status(404)
624+
self.finish(json.dumps({"error": str(e)}))
625+
except PathNotDirectoryError as e:
626+
self.set_status(400)
627+
self.finish(json.dumps({"error": str(e)}))
628+
except TypeNotCreatableError as e:
629+
self.set_status(400)
630+
self.finish(json.dumps({"error": str(e)}))
631+
except Exception as e:
632+
logger.error("Create project error: %s", e, exc_info=True)
633+
self.set_status(500)
634+
self.finish(json.dumps({"error": f"Failed to create project type: {e}"}))
635+
636+
def _run_create(self, path: str, type_name: str) -> dict:
637+
"""Resolve path and run projspec create (called in thread pool)."""
638+
absolute_path = resolve_path(self.contents_manager, path)
639+
640+
if not _is_type_creatable(type_name):
641+
raise TypeNotCreatableError(
642+
f"Project type '{type_name}' does not support creation"
643+
)
644+
645+
project = projspec.Project(absolute_path)
646+
647+
if type_name in project.specs:
648+
raise TypeNotCreatableError(
649+
f"Project type '{type_name}' already exists in this directory"
650+
)
651+
652+
# project.create() returns the list of newly created file paths
653+
created_files = project.create(type_name)
654+
return {"created_files": [str(f) for f in created_files]}
655+
656+
511657
def setup_route_handlers(web_app):
512658
host_pattern = ".*$"
513659
base_url = web_app.settings["base_url"]
514660

515661
scan_route_pattern = url_path_join(base_url, "jupyter-projspec", "scan")
516662
make_route_pattern = url_path_join(base_url, "jupyter-projspec", "make")
663+
creatable_types_pattern = url_path_join(
664+
base_url, "jupyter-projspec", "creatable-types"
665+
)
666+
create_pattern = url_path_join(base_url, "jupyter-projspec", "create")
517667

518668
handlers = [
519669
(scan_route_pattern, ScanRouteHandler),
520670
(make_route_pattern, MakeRouteHandler),
671+
(creatable_types_pattern, CreatableTypesRouteHandler),
672+
(create_pattern, CreateRouteHandler),
521673
]
522674

523675
web_app.add_handlers(host_pattern, handlers)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "jupyter-projspec",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "A Jupyter interface for projspec",
55
"keywords": [
66
"jupyter",
@@ -58,6 +58,7 @@
5858
},
5959
"dependencies": {
6060
"@jupyterlab/application": "^4.0.0",
61+
"@jupyterlab/apputils": "^4.0.0",
6162
"@jupyterlab/coreutils": "^6.0.0",
6263
"@jupyterlab/filebrowser": "^4.0.0",
6364
"@jupyterlab/services": "^7.0.0",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ classifiers = [
2323
]
2424
dependencies = [
2525
"jupyter_server>=2.4.0,<3",
26-
"projspec>=0.2.0,<0.3"
26+
"projspec>0.2.0,<0.3"
2727
]
2828
dynamic = ["version", "description", "authors", "urls", "keywords"]
2929

src/api.ts

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ServerConnection } from '@jupyterlab/services';
2+
import { ICreatableType, ICreateRequest, ICreateResponse } from './types';
23
import { requestAPI } from './request';
34

45
/**
@@ -25,6 +26,28 @@ interface IMakeResponse {
2526
truncated: boolean;
2627
}
2728

29+
/**
30+
* Extract a user-friendly error message from a caught error.
31+
*/
32+
function extractErrorMessage(err: unknown, context: string): string {
33+
if (err instanceof ServerConnection.ResponseError) {
34+
const status = err.response.status;
35+
let detail = err.message;
36+
37+
if (
38+
typeof detail === 'string' &&
39+
(detail.includes('<!DOCTYPE') || detail.includes('<html'))
40+
) {
41+
detail = `HTML error page (${detail.substring(0, 100)}...)`;
42+
}
43+
44+
return `${context} (${status}): ${detail}`;
45+
}
46+
47+
const msg = err instanceof Error ? err.message : 'Unknown error';
48+
return `${context}: ${msg}`;
49+
}
50+
2851
/**
2952
* Execute an artifact's build command via the backend.
3053
*
@@ -46,22 +69,64 @@ export async function make(request: IMakeRequest): Promise<IMakeResponse> {
4669
}
4770
return response;
4871
} catch (err) {
49-
if (err instanceof ServerConnection.ResponseError) {
50-
const status = err.response.status;
51-
let detail = err.message;
72+
throw new Error(extractErrorMessage(err, 'Make request failed'));
73+
}
74+
}
75+
76+
/**
77+
* Module-level cache for creatable types.
78+
* The list is static for the lifetime of the server, so we fetch once.
79+
*/
80+
let creatableTypesCache: ICreatableType[] | null = null;
5281

53-
// Truncate HTML responses for cleaner error messages
54-
if (
55-
typeof detail === 'string' &&
56-
(detail.includes('<!DOCTYPE') || detail.includes('<html'))
57-
) {
58-
detail = `HTML error page (${detail.substring(0, 100)}...)`;
59-
}
82+
/**
83+
* Fetch the list of project types that support creation.
84+
*
85+
* Results are cached after the first successful call since the
86+
* projspec registry does not change at runtime.
87+
*/
88+
export async function fetchCreatableTypes(): Promise<ICreatableType[]> {
89+
if (creatableTypesCache !== null) {
90+
return creatableTypesCache;
91+
}
6092

61-
throw new Error(`Make request failed (${status}): ${detail}`);
93+
try {
94+
const response = await requestAPI<{ types: ICreatableType[] }>(
95+
'creatable-types',
96+
{ method: 'GET' }
97+
);
98+
if (!response?.types) {
99+
throw new Error('Empty response from server');
62100
}
101+
creatableTypesCache = response.types;
102+
return creatableTypesCache;
103+
} catch (err) {
104+
throw new Error(
105+
extractErrorMessage(err, 'Failed to fetch creatable types')
106+
);
107+
}
108+
}
63109

64-
const msg = err instanceof Error ? err.message : 'Unknown error';
65-
throw new Error(`Make request failed: ${msg}`);
110+
/**
111+
* Create a new project type in the specified directory.
112+
*
113+
* @param request - The path and type_name for creation
114+
* @returns The list of files created by projspec
115+
*/
116+
export async function createProject(
117+
request: ICreateRequest
118+
): Promise<ICreateResponse> {
119+
try {
120+
const response = await requestAPI<ICreateResponse>('create', {
121+
method: 'POST',
122+
headers: { 'Content-Type': 'application/json' },
123+
body: JSON.stringify(request)
124+
});
125+
if (response === undefined) {
126+
throw new Error('Create request returned an empty response');
127+
}
128+
return response;
129+
} catch (err) {
130+
throw new Error(extractErrorMessage(err, 'Create project failed'));
66131
}
67132
}

0 commit comments

Comments
 (0)