Skip to content

Commit 269df04

Browse files
committed
feat: implement environment-scoped package management and routing
1 parent 998a737 commit 269df04

12 files changed

Lines changed: 420 additions & 140 deletions

apps/studio/src/components/app-sidebar.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,12 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
167167
packages: InstalledPackage[];
168168
selectedPackage: InstalledPackage | null;
169169
onSelectPackage: (pkg: InstalledPackage) => void;
170+
/** When set, all package-content URLs are rooted at /environments/:envId/:pkg/* */
171+
environmentId?: string;
170172
}
171173

172174
export function AppSidebar({
173-
packages, selectedPackage, onSelectPackage,
175+
packages, selectedPackage, onSelectPackage, environmentId,
174176
...props
175177
}: AppSidebarProps) {
176178
const client = useClient();
@@ -365,7 +367,14 @@ export function AppSidebar({
365367
<SidebarMenuItem>
366368
<SidebarMenuButton
367369
isActive={!!params.package && !params.name && !params.type}
368-
onClick={() => navigate({ to: `/${selectedPackage?.manifest?.id || 'default'}` })}
370+
onClick={() => {
371+
const pkgId = selectedPackage?.manifest?.id || 'default';
372+
if (environmentId) {
373+
navigate({ to: `/environments/${environmentId}/${pkgId}/` });
374+
} else {
375+
navigate({ to: `/${pkgId}` });
376+
}
377+
}}
369378
>
370379
<LayoutDashboard className="h-4 w-4" />
371380
<span>Overview</span>
@@ -461,8 +470,12 @@ export function AppSidebar({
461470

462471
const packagePath = selectedPackage?.manifest?.id || 'default';
463472
const handleClick = isObjectType
464-
? () => navigate({ to: `/${packagePath}/objects/${itemName}` })
465-
: () => navigate({ to: `/${packagePath}/metadata/${type}/${itemName}` });
473+
? () => environmentId
474+
? navigate({ to: `/environments/${environmentId}/${packagePath}/objects/${itemName}` })
475+
: navigate({ to: `/${packagePath}/objects/${itemName}` })
476+
: () => environmentId
477+
? navigate({ to: `/environments/${environmentId}/${packagePath}/metadata/${type}/${itemName}` })
478+
: navigate({ to: `/${packagePath}/metadata/${type}/${itemName}` });
466479

467480
return (
468481
<SidebarMenuSubItem key={itemName}>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useState, useMemo } from 'react';
4+
import type { InstalledPackage } from '@objectstack/spec/kernel';
5+
import { usePackages } from './usePackages';
6+
import { useEnvironmentPackages } from './useEnvironmentPackages';
7+
8+
/**
9+
* Returns the subset of the global package registry that is installed in the
10+
* given environment. Combines:
11+
* - `useEnvironmentPackages(envId)` — per-env DB records (have short packageId)
12+
* - `usePackages()` — global runtime registry (have full manifest.id)
13+
*
14+
* The cross-reference is fuzzy: a record with `packageId = "crm"` matches a
15+
* manifest with `id = "com.example.crm"` (last dot-segment equality).
16+
*/
17+
export function useEnvAwarePackages(envId: string | undefined) {
18+
const { packages: globalPkgs } = usePackages();
19+
const { packages: installedRecords } = useEnvironmentPackages(envId);
20+
21+
const [selectedPackage, setSelectedPackage] = useState<InstalledPackage | null>(null);
22+
23+
const packages = useMemo<InstalledPackage[]>(() => {
24+
if (!installedRecords.length || !globalPkgs.length) return [];
25+
26+
// Build a set of all installed identifiers (full + short segment)
27+
const installedIds = new Set<string>();
28+
for (const rec of installedRecords) {
29+
const id = rec.packageId;
30+
if (!id) continue;
31+
installedIds.add(id);
32+
// If already a reverse-domain ID, add short segment too
33+
const seg = id.split('.').pop();
34+
if (seg) installedIds.add(seg);
35+
}
36+
37+
// Keep global packages whose manifest.id is in the installed set (full or short)
38+
return globalPkgs.filter((p) => {
39+
const mid = p.manifest?.id ?? '';
40+
if (!mid) return false;
41+
if (installedIds.has(mid)) return true;
42+
// Match by last segment: "com.example.crm" → "crm"
43+
const seg = mid.split('.').pop();
44+
return seg ? installedIds.has(seg) : false;
45+
});
46+
}, [globalPkgs, installedRecords]);
47+
48+
return { packages, selectedPackage, setSelectedPackage };
49+
}

apps/studio/src/routeTree.gen.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ import { Route as OrgsOrgIdRouteImport } from './routes/orgs.$orgId'
2323
import { Route as EnvironmentsEnvironmentIdRouteImport } from './routes/environments.$environmentId'
2424
import { Route as EnvironmentsEnvironmentIdIndexRouteImport } from './routes/environments.$environmentId.index'
2525
import { Route as EnvironmentsEnvironmentIdPackagesRouteImport } from './routes/environments.$environmentId.packages'
26+
import { Route as EnvironmentsEnvironmentIdPackageRouteImport } from './routes/environments.$environmentId.$package'
2627
import { Route as PackageObjectsNameRouteImport } from './routes/$package.objects.$name'
28+
import { Route as EnvironmentsEnvironmentIdPackageIndexRouteImport } from './routes/environments.$environmentId.$package.index'
2729
import { Route as PackageMetadataTypeNameRouteImport } from './routes/$package.metadata.$type.$name'
30+
import { Route as EnvironmentsEnvironmentIdPackageObjectsNameRouteImport } from './routes/environments.$environmentId.$package.objects.$name'
31+
import { Route as EnvironmentsEnvironmentIdPackageMetadataTypeNameRouteImport } from './routes/environments.$environmentId.$package.metadata.$type.$name'
2832

2933
const RegisterRoute = RegisterRouteImport.update({
3034
id: '/register',
@@ -99,16 +103,40 @@ const EnvironmentsEnvironmentIdPackagesRoute =
99103
path: '/packages',
100104
getParentRoute: () => EnvironmentsEnvironmentIdRoute,
101105
} as any)
106+
const EnvironmentsEnvironmentIdPackageRoute =
107+
EnvironmentsEnvironmentIdPackageRouteImport.update({
108+
id: '/$package',
109+
path: '/$package',
110+
getParentRoute: () => EnvironmentsEnvironmentIdRoute,
111+
} as any)
102112
const PackageObjectsNameRoute = PackageObjectsNameRouteImport.update({
103113
id: '/objects/$name',
104114
path: '/objects/$name',
105115
getParentRoute: () => PackageRoute,
106116
} as any)
117+
const EnvironmentsEnvironmentIdPackageIndexRoute =
118+
EnvironmentsEnvironmentIdPackageIndexRouteImport.update({
119+
id: '/',
120+
path: '/',
121+
getParentRoute: () => EnvironmentsEnvironmentIdPackageRoute,
122+
} as any)
107123
const PackageMetadataTypeNameRoute = PackageMetadataTypeNameRouteImport.update({
108124
id: '/metadata/$type/$name',
109125
path: '/metadata/$type/$name',
110126
getParentRoute: () => PackageRoute,
111127
} as any)
128+
const EnvironmentsEnvironmentIdPackageObjectsNameRoute =
129+
EnvironmentsEnvironmentIdPackageObjectsNameRouteImport.update({
130+
id: '/objects/$name',
131+
path: '/objects/$name',
132+
getParentRoute: () => EnvironmentsEnvironmentIdPackageRoute,
133+
} as any)
134+
const EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute =
135+
EnvironmentsEnvironmentIdPackageMetadataTypeNameRouteImport.update({
136+
id: '/metadata/$type/$name',
137+
path: '/metadata/$type/$name',
138+
getParentRoute: () => EnvironmentsEnvironmentIdPackageRoute,
139+
} as any)
112140

113141
export interface FileRoutesByFullPath {
114142
'/': typeof IndexRoute
@@ -124,9 +152,13 @@ export interface FileRoutesByFullPath {
124152
'/environments/': typeof EnvironmentsIndexRoute
125153
'/orgs/': typeof OrgsIndexRoute
126154
'/$package/objects/$name': typeof PackageObjectsNameRoute
155+
'/environments/$environmentId/$package': typeof EnvironmentsEnvironmentIdPackageRouteWithChildren
127156
'/environments/$environmentId/packages': typeof EnvironmentsEnvironmentIdPackagesRoute
128157
'/environments/$environmentId/': typeof EnvironmentsEnvironmentIdIndexRoute
129158
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
159+
'/environments/$environmentId/$package/': typeof EnvironmentsEnvironmentIdPackageIndexRoute
160+
'/environments/$environmentId/$package/objects/$name': typeof EnvironmentsEnvironmentIdPackageObjectsNameRoute
161+
'/environments/$environmentId/$package/metadata/$type/$name': typeof EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute
130162
}
131163
export interface FileRoutesByTo {
132164
'/': typeof IndexRoute
@@ -143,6 +175,9 @@ export interface FileRoutesByTo {
143175
'/environments/$environmentId/packages': typeof EnvironmentsEnvironmentIdPackagesRoute
144176
'/environments/$environmentId': typeof EnvironmentsEnvironmentIdIndexRoute
145177
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
178+
'/environments/$environmentId/$package': typeof EnvironmentsEnvironmentIdPackageIndexRoute
179+
'/environments/$environmentId/$package/objects/$name': typeof EnvironmentsEnvironmentIdPackageObjectsNameRoute
180+
'/environments/$environmentId/$package/metadata/$type/$name': typeof EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute
146181
}
147182
export interface FileRoutesById {
148183
__root__: typeof rootRouteImport
@@ -159,9 +194,13 @@ export interface FileRoutesById {
159194
'/environments/': typeof EnvironmentsIndexRoute
160195
'/orgs/': typeof OrgsIndexRoute
161196
'/$package/objects/$name': typeof PackageObjectsNameRoute
197+
'/environments/$environmentId/$package': typeof EnvironmentsEnvironmentIdPackageRouteWithChildren
162198
'/environments/$environmentId/packages': typeof EnvironmentsEnvironmentIdPackagesRoute
163199
'/environments/$environmentId/': typeof EnvironmentsEnvironmentIdIndexRoute
164200
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
201+
'/environments/$environmentId/$package/': typeof EnvironmentsEnvironmentIdPackageIndexRoute
202+
'/environments/$environmentId/$package/objects/$name': typeof EnvironmentsEnvironmentIdPackageObjectsNameRoute
203+
'/environments/$environmentId/$package/metadata/$type/$name': typeof EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute
165204
}
166205
export interface FileRouteTypes {
167206
fileRoutesByFullPath: FileRoutesByFullPath
@@ -179,9 +218,13 @@ export interface FileRouteTypes {
179218
| '/environments/'
180219
| '/orgs/'
181220
| '/$package/objects/$name'
221+
| '/environments/$environmentId/$package'
182222
| '/environments/$environmentId/packages'
183223
| '/environments/$environmentId/'
184224
| '/$package/metadata/$type/$name'
225+
| '/environments/$environmentId/$package/'
226+
| '/environments/$environmentId/$package/objects/$name'
227+
| '/environments/$environmentId/$package/metadata/$type/$name'
185228
fileRoutesByTo: FileRoutesByTo
186229
to:
187230
| '/'
@@ -198,6 +241,9 @@ export interface FileRouteTypes {
198241
| '/environments/$environmentId/packages'
199242
| '/environments/$environmentId'
200243
| '/$package/metadata/$type/$name'
244+
| '/environments/$environmentId/$package'
245+
| '/environments/$environmentId/$package/objects/$name'
246+
| '/environments/$environmentId/$package/metadata/$type/$name'
201247
id:
202248
| '__root__'
203249
| '/'
@@ -213,9 +259,13 @@ export interface FileRouteTypes {
213259
| '/environments/'
214260
| '/orgs/'
215261
| '/$package/objects/$name'
262+
| '/environments/$environmentId/$package'
216263
| '/environments/$environmentId/packages'
217264
| '/environments/$environmentId/'
218265
| '/$package/metadata/$type/$name'
266+
| '/environments/$environmentId/$package/'
267+
| '/environments/$environmentId/$package/objects/$name'
268+
| '/environments/$environmentId/$package/metadata/$type/$name'
219269
fileRoutesById: FileRoutesById
220270
}
221271
export interface RootRouteChildren {
@@ -332,20 +382,48 @@ declare module '@tanstack/react-router' {
332382
preLoaderRoute: typeof EnvironmentsEnvironmentIdPackagesRouteImport
333383
parentRoute: typeof EnvironmentsEnvironmentIdRoute
334384
}
385+
'/environments/$environmentId/$package': {
386+
id: '/environments/$environmentId/$package'
387+
path: '/$package'
388+
fullPath: '/environments/$environmentId/$package'
389+
preLoaderRoute: typeof EnvironmentsEnvironmentIdPackageRouteImport
390+
parentRoute: typeof EnvironmentsEnvironmentIdRoute
391+
}
335392
'/$package/objects/$name': {
336393
id: '/$package/objects/$name'
337394
path: '/objects/$name'
338395
fullPath: '/$package/objects/$name'
339396
preLoaderRoute: typeof PackageObjectsNameRouteImport
340397
parentRoute: typeof PackageRoute
341398
}
399+
'/environments/$environmentId/$package/': {
400+
id: '/environments/$environmentId/$package/'
401+
path: '/'
402+
fullPath: '/environments/$environmentId/$package/'
403+
preLoaderRoute: typeof EnvironmentsEnvironmentIdPackageIndexRouteImport
404+
parentRoute: typeof EnvironmentsEnvironmentIdPackageRoute
405+
}
342406
'/$package/metadata/$type/$name': {
343407
id: '/$package/metadata/$type/$name'
344408
path: '/metadata/$type/$name'
345409
fullPath: '/$package/metadata/$type/$name'
346410
preLoaderRoute: typeof PackageMetadataTypeNameRouteImport
347411
parentRoute: typeof PackageRoute
348412
}
413+
'/environments/$environmentId/$package/objects/$name': {
414+
id: '/environments/$environmentId/$package/objects/$name'
415+
path: '/objects/$name'
416+
fullPath: '/environments/$environmentId/$package/objects/$name'
417+
preLoaderRoute: typeof EnvironmentsEnvironmentIdPackageObjectsNameRouteImport
418+
parentRoute: typeof EnvironmentsEnvironmentIdPackageRoute
419+
}
420+
'/environments/$environmentId/$package/metadata/$type/$name': {
421+
id: '/environments/$environmentId/$package/metadata/$type/$name'
422+
path: '/metadata/$type/$name'
423+
fullPath: '/environments/$environmentId/$package/metadata/$type/$name'
424+
preLoaderRoute: typeof EnvironmentsEnvironmentIdPackageMetadataTypeNameRouteImport
425+
parentRoute: typeof EnvironmentsEnvironmentIdPackageRoute
426+
}
349427
}
350428
}
351429

@@ -364,13 +442,37 @@ const PackageRouteChildren: PackageRouteChildren = {
364442
const PackageRouteWithChildren =
365443
PackageRoute._addFileChildren(PackageRouteChildren)
366444

445+
interface EnvironmentsEnvironmentIdPackageRouteChildren {
446+
EnvironmentsEnvironmentIdPackageIndexRoute: typeof EnvironmentsEnvironmentIdPackageIndexRoute
447+
EnvironmentsEnvironmentIdPackageObjectsNameRoute: typeof EnvironmentsEnvironmentIdPackageObjectsNameRoute
448+
EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute: typeof EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute
449+
}
450+
451+
const EnvironmentsEnvironmentIdPackageRouteChildren: EnvironmentsEnvironmentIdPackageRouteChildren =
452+
{
453+
EnvironmentsEnvironmentIdPackageIndexRoute:
454+
EnvironmentsEnvironmentIdPackageIndexRoute,
455+
EnvironmentsEnvironmentIdPackageObjectsNameRoute:
456+
EnvironmentsEnvironmentIdPackageObjectsNameRoute,
457+
EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute:
458+
EnvironmentsEnvironmentIdPackageMetadataTypeNameRoute,
459+
}
460+
461+
const EnvironmentsEnvironmentIdPackageRouteWithChildren =
462+
EnvironmentsEnvironmentIdPackageRoute._addFileChildren(
463+
EnvironmentsEnvironmentIdPackageRouteChildren,
464+
)
465+
367466
interface EnvironmentsEnvironmentIdRouteChildren {
467+
EnvironmentsEnvironmentIdPackageRoute: typeof EnvironmentsEnvironmentIdPackageRouteWithChildren
368468
EnvironmentsEnvironmentIdPackagesRoute: typeof EnvironmentsEnvironmentIdPackagesRoute
369469
EnvironmentsEnvironmentIdIndexRoute: typeof EnvironmentsEnvironmentIdIndexRoute
370470
}
371471

372472
const EnvironmentsEnvironmentIdRouteChildren: EnvironmentsEnvironmentIdRouteChildren =
373473
{
474+
EnvironmentsEnvironmentIdPackageRoute:
475+
EnvironmentsEnvironmentIdPackageRouteWithChildren,
374476
EnvironmentsEnvironmentIdPackagesRoute:
375477
EnvironmentsEnvironmentIdPackagesRoute,
376478
EnvironmentsEnvironmentIdIndexRoute: EnvironmentsEnvironmentIdIndexRoute,
Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,28 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { createFileRoute } from '@tanstack/react-router';
4-
import { SiteHeader } from '@/components/site-header';
5-
import { DeveloperOverview } from '../components/DeveloperOverview';
6-
import { usePackages } from '../hooks/usePackages';
7-
83
/**
9-
* Leaf route for the exact `/$package` URL — the package overview page.
10-
*
11-
* Owns its own `SiteHeader`; the layout shell (sidebar + main wrapper) is
12-
* provided by the parent `$package.tsx` route.
4+
* Legacy `/$package/` index — handled by the parent `/$package` redirect.
5+
* This file must exist so TanStack Router doesn't generate a 404 for the
6+
* exact index path before the parent redirect fires.
137
*/
14-
function PackageIndexComponent() {
15-
const { packages, selectedPackage } = usePackages();
168

17-
return (
18-
<>
19-
<SiteHeader
20-
selectedView="overview"
21-
packageLabel={selectedPackage?.manifest?.name || selectedPackage?.manifest?.id}
22-
/>
23-
<div className="flex flex-1 flex-col overflow-hidden">
24-
<DeveloperOverview
25-
packages={packages}
26-
selectedPackage={selectedPackage}
27-
/>
28-
</div>
29-
</>
30-
);
31-
}
9+
import { createFileRoute, redirect } from '@tanstack/react-router';
3210

3311
export const Route = createFileRoute('/$package/')({
34-
component: PackageIndexComponent,
12+
beforeLoad: ({ params }) => {
13+
const lastEnvId =
14+
typeof localStorage !== 'undefined'
15+
? localStorage.getItem('objectstack.lastEnvId')
16+
: null;
17+
18+
if (lastEnvId) {
19+
throw redirect({
20+
to: '/environments/$environmentId/$package/',
21+
params: { environmentId: lastEnvId, package: params.package },
22+
replace: true,
23+
});
24+
}
25+
throw redirect({ to: '/environments', replace: true });
26+
},
27+
component: () => null,
3528
});

0 commit comments

Comments
 (0)