@finos/legend-application-studio 28.21.5 → 28.21.7

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.
Files changed (74) hide show
  1. package/lib/__lib__/LegendStudioEvent.d.ts +4 -1
  2. package/lib/__lib__/LegendStudioEvent.d.ts.map +1 -1
  3. package/lib/__lib__/LegendStudioEvent.js +3 -0
  4. package/lib/__lib__/LegendStudioEvent.js.map +1 -1
  5. package/lib/__lib__/LegendStudioTelemetryHelper.d.ts +2 -1
  6. package/lib/__lib__/LegendStudioTelemetryHelper.d.ts.map +1 -1
  7. package/lib/__lib__/LegendStudioTelemetryHelper.js +11 -3
  8. package/lib/__lib__/LegendStudioTelemetryHelper.js.map +1 -1
  9. package/lib/__lib__/LegendStudioUserDataHelper.d.ts +63 -1
  10. package/lib/__lib__/LegendStudioUserDataHelper.d.ts.map +1 -1
  11. package/lib/__lib__/LegendStudioUserDataHelper.js +185 -1
  12. package/lib/__lib__/LegendStudioUserDataHelper.js.map +1 -1
  13. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.d.ts.map +1 -1
  14. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.js +1 -1
  15. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.js.map +1 -1
  16. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.d.ts.map +1 -1
  17. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js +2 -1
  18. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js.map +1 -1
  19. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  20. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +1 -1
  21. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  22. package/lib/components/editor/editor-group/testable/TestableSharedComponents.d.ts.map +1 -1
  23. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js +1 -1
  24. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js.map +1 -1
  25. package/lib/components/workspace-setup/RecentWorkspacesPanel.d.ts +22 -0
  26. package/lib/components/workspace-setup/RecentWorkspacesPanel.d.ts.map +1 -0
  27. package/lib/components/workspace-setup/RecentWorkspacesPanel.js +86 -0
  28. package/lib/components/workspace-setup/RecentWorkspacesPanel.js.map +1 -0
  29. package/lib/components/workspace-setup/WorkspaceSetup.d.ts.map +1 -1
  30. package/lib/components/workspace-setup/WorkspaceSetup.js +60 -6
  31. package/lib/components/workspace-setup/WorkspaceSetup.js.map +1 -1
  32. package/lib/index.css +2 -2
  33. package/lib/index.css.map +1 -1
  34. package/lib/package.json +1 -1
  35. package/lib/stores/editor/EditorStore.d.ts.map +1 -1
  36. package/lib/stores/editor/EditorStore.js +34 -0
  37. package/lib/stores/editor/EditorStore.js.map +1 -1
  38. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.d.ts.map +1 -1
  39. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.js +4 -2
  40. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.js.map +1 -1
  41. package/lib/stores/editor/sidebar-state/ProjectOverviewState.d.ts.map +1 -1
  42. package/lib/stores/editor/sidebar-state/ProjectOverviewState.js +11 -0
  43. package/lib/stores/editor/sidebar-state/ProjectOverviewState.js.map +1 -1
  44. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.d.ts.map +1 -1
  45. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.js +11 -0
  46. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.js.map +1 -1
  47. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.d.ts.map +1 -1
  48. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.js +2 -1
  49. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.js.map +1 -1
  50. package/lib/stores/project-reviewer/ProjectReviewerStore.d.ts.map +1 -1
  51. package/lib/stores/project-reviewer/ProjectReviewerStore.js +12 -0
  52. package/lib/stores/project-reviewer/ProjectReviewerStore.js.map +1 -1
  53. package/lib/stores/workspace-setup/WorkspaceSetupStore.d.ts +23 -2
  54. package/lib/stores/workspace-setup/WorkspaceSetupStore.d.ts.map +1 -1
  55. package/lib/stores/workspace-setup/WorkspaceSetupStore.js +121 -8
  56. package/lib/stores/workspace-setup/WorkspaceSetupStore.js.map +1 -1
  57. package/package.json +10 -10
  58. package/src/__lib__/LegendStudioEvent.ts +3 -0
  59. package/src/__lib__/LegendStudioTelemetryHelper.ts +35 -11
  60. package/src/__lib__/LegendStudioUserDataHelper.ts +309 -1
  61. package/src/components/editor/editor-group/data-editor/EmbeddedDataEditor.tsx +1 -0
  62. package/src/components/editor/editor-group/data-editor/RelationElementsDataEditor.tsx +13 -5
  63. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +1 -0
  64. package/src/components/editor/editor-group/testable/TestableSharedComponents.tsx +1 -0
  65. package/src/components/workspace-setup/RecentWorkspacesPanel.tsx +181 -0
  66. package/src/components/workspace-setup/WorkspaceSetup.tsx +96 -8
  67. package/src/stores/editor/EditorStore.ts +47 -0
  68. package/src/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.ts +5 -1
  69. package/src/stores/editor/sidebar-state/ProjectOverviewState.ts +14 -0
  70. package/src/stores/editor/sidebar-state/WorkspaceReviewState.ts +14 -0
  71. package/src/stores/editor/sidebar-state/dev-metadata/DevMetadataState.ts +8 -1
  72. package/src/stores/project-reviewer/ProjectReviewerStore.ts +15 -0
  73. package/src/stores/workspace-setup/WorkspaceSetupStore.ts +172 -9
  74. package/tsconfig.json +1 -0
@@ -52,6 +52,7 @@ import { CreateProjectModal } from './CreateProjectModal.js';
52
52
  import { ActivityBarMenu } from '../editor/ActivityBar.js';
53
53
  import { LEGEND_STUDIO_APPLICATION_NAVIGATION_CONTEXT_KEY } from '../../__lib__/LegendStudioApplicationNavigationContext.js';
54
54
  import { CreateWorkspaceModal } from './CreateWorkspaceModal.js';
55
+ import { RecentWorkspacesPanel } from './RecentWorkspacesPanel.js';
55
56
  import {
56
57
  useLegendStudioApplicationStore,
57
58
  useLegendStudioBaseStore,
@@ -66,7 +67,12 @@ import {
66
67
  buildWorkspaceOption,
67
68
  formatWorkspaceOptionLabel,
68
69
  } from './WorkspaceSelectorUtils.js';
69
- import { debounce, guaranteeNonNullable } from '@finos/legend-shared';
70
+ import {
71
+ debounce,
72
+ guaranteeNonNullable,
73
+ type PlainObject,
74
+ } from '@finos/legend-shared';
75
+ import { Project } from '@finos/legend-server-sdlc';
70
76
  import { WorkspaceSetupStore } from '../../stores/workspace-setup/WorkspaceSetupStore.js';
71
77
  import { openShowcaseManager } from '../../stores/ShowcaseManagerState.js';
72
78
 
@@ -360,18 +366,56 @@ export const WorkspaceSetup = withWorkspaceSetupStore(
360
366
  applicationStore.assistantService.toggleAssistant();
361
367
 
362
368
  // projects
363
- const projectOptions = setupStore.projects
369
+ // Build a unified option list: recent projects (that aren't already in the
370
+ // loaded set) are prepended so users can instantly re-open common work
371
+ // without waiting for the SDLC search to round-trip.
372
+ const loadedProjectOptions = setupStore.projects
364
373
  .map(buildProjectOption)
365
374
  .sort(compareLabelFn);
375
+ const loadedProjectIds = new Set(
376
+ setupStore.projects.map((p) => p.projectId),
377
+ );
378
+ const recentProjectOptions: ProjectOption[] = setupStore.recentProjects
379
+ .filter((r) => !loadedProjectIds.has(r.projectId))
380
+ .map((r) => {
381
+ // Rebuild a real Project from the cached metadata; no synthetic
382
+ // fields needed since we persist everything the schema requires.
383
+ const stub = Project.serialization.fromJson({
384
+ projectId: r.projectId,
385
+ name: r.name,
386
+ description: r.description,
387
+ webUrl: r.webUrl,
388
+ tags: r.tags,
389
+ } as PlainObject<Project>);
390
+ return { label: stub.name, value: stub };
391
+ });
392
+ const projectOptions: ProjectOption[] = [
393
+ ...recentProjectOptions,
394
+ ...loadedProjectOptions,
395
+ ];
396
+ const recentProjectIdSet = new Set(
397
+ setupStore.recentProjects.map((p) => p.projectId),
398
+ );
366
399
  const selectedProjectOption = setupStore.currentProject
367
400
  ? buildProjectOption(setupStore.currentProject)
368
401
  : null;
369
402
 
370
403
  const onProjectChange = (val: ProjectOption | null): void => {
371
404
  if (val) {
372
- flowResult(setupStore.changeProject(val.value)).catch(
373
- applicationStore.alertUnhandledError,
374
- );
405
+ // If the selection corresponds to a recent that hasn't been loaded
406
+ // from search yet, fetch the project before switching.
407
+ const isUnloadedRecent =
408
+ !loadedProjectIds.has(val.value.projectId) &&
409
+ recentProjectIdSet.has(val.value.projectId);
410
+ if (isUnloadedRecent) {
411
+ flowResult(setupStore.selectRecentProject(val.value.projectId)).catch(
412
+ applicationStore.alertUnhandledError,
413
+ );
414
+ } else {
415
+ flowResult(setupStore.changeProject(val.value)).catch(
416
+ applicationStore.alertUnhandledError,
417
+ );
418
+ }
375
419
  } else {
376
420
  setupStore.resetProject();
377
421
  }
@@ -404,9 +448,40 @@ export const WorkspaceSetup = withWorkspaceSetupStore(
404
448
  };
405
449
 
406
450
  // workspaces
407
- const workspaceOptions = setupStore.workspaces
408
- .map(buildWorkspaceOption)
451
+ // Recent (non-patch) workspaces for the currently selected project are
452
+ // floated to the top of the dropdown so users can re-enter their
453
+ // typical work without scanning the full list.
454
+ const projectIdForRecents = setupStore.currentProject?.projectId;
455
+ const recentWorkspaceKeys = new Set(
456
+ projectIdForRecents
457
+ ? setupStore.recentWorkspaces
458
+ .filter((w) => w.projectId === projectIdForRecents)
459
+ .map((w) => `${w.workspaceType}::${w.workspaceId}`)
460
+ : [],
461
+ );
462
+ const allWorkspaceOptions = setupStore.workspaces.map(buildWorkspaceOption);
463
+ const recentWorkspaceOptions = allWorkspaceOptions
464
+ .filter(
465
+ (o) =>
466
+ o.value.source === undefined &&
467
+ recentWorkspaceKeys.has(
468
+ `${o.value.workspaceType}::${o.value.workspaceId}`,
469
+ ),
470
+ )
471
+ .sort(compareLabelFn);
472
+ const otherWorkspaceOptions = allWorkspaceOptions
473
+ .filter(
474
+ (o) =>
475
+ o.value.source !== undefined ||
476
+ !recentWorkspaceKeys.has(
477
+ `${o.value.workspaceType}::${o.value.workspaceId}`,
478
+ ),
479
+ )
409
480
  .sort(compareLabelFn);
481
+ const workspaceOptions = [
482
+ ...recentWorkspaceOptions,
483
+ ...otherWorkspaceOptions,
484
+ ];
410
485
  const selectedWorkspaceOption = setupStore.currentWorkspace
411
486
  ? buildWorkspaceOption(setupStore.currentWorkspace)
412
487
  : null;
@@ -489,11 +564,24 @@ export const WorkspaceSetup = withWorkspaceSetupStore(
489
564
  Welcome to Legend Studio
490
565
  </div>
491
566
  </div>
567
+ <RecentWorkspacesPanel setupStore={setupStore} />
492
568
  <div className="workspace-setup__selectors">
493
569
  <div className="workspace-setup__selectors__container">
494
570
  <div className="workspace-setup__selector">
495
571
  <div className="workspace-setup__selector__header">
496
- Search for an existing project
572
+ <span>Search for an existing project</span>
573
+ {(setupStore.recentProjects.length > 0 ||
574
+ setupStore.recentWorkspaces.length > 0) && (
575
+ <button
576
+ type="button"
577
+ className="workspace-setup__selector__header__clear-recents"
578
+ tabIndex={-1}
579
+ onClick={() => setupStore.clearRecents()}
580
+ title="Clear recently-opened projects and workspaces"
581
+ >
582
+ Clear recents
583
+ </button>
584
+ )}
497
585
  </div>
498
586
  <div className="workspace-setup__selector__content">
499
587
  <div
@@ -93,6 +93,7 @@ import {
93
93
  DEFAULT_TAB_SIZE,
94
94
  } from '@finos/legend-application';
95
95
  import { LEGEND_STUDIO_APP_EVENT } from '../../__lib__/LegendStudioEvent.js';
96
+ import { LegendStudioUserDataHelper } from '../../__lib__/LegendStudioUserDataHelper.js';
96
97
  import type { EditorMode } from './EditorMode.js';
97
98
  import { StandardEditorMode } from './StandardEditorMode.js';
98
99
  import { WorkspaceUpdateConflictResolutionState } from './sidebar-state/WorkspaceUpdateConflictResolutionState.js';
@@ -691,6 +692,12 @@ export class EditorStore implements CommandRegistrar {
691
692
  }),
692
693
  );
693
694
  if (!this.sdlcState.currentProject) {
695
+ // The project the user navigated to doesn't exist (or isn't accessible).
696
+ // Drop it from the recents cache so we don't keep offering a dead link.
697
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentProject(
698
+ this.applicationStore.userDataService,
699
+ projectId,
700
+ );
694
701
  // If the project is not found or the user does not have access to it,
695
702
  // we will not automatically redirect them to the setup page as they will lose the URL
696
703
  // instead, we give them the option to:
@@ -740,6 +747,15 @@ export class EditorStore implements CommandRegistrar {
740
747
  ),
741
748
  );
742
749
  if (!this.sdlcState.currentWorkspace) {
750
+ // The workspace the user navigated to doesn't exist anymore. Drop
751
+ // the matching entry from the recents cache (no-op for patch
752
+ // workspaces, which are never cached in the first place).
753
+ if (patchReleaseVersionId === undefined) {
754
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentWorkspace(
755
+ this.applicationStore.userDataService,
756
+ { projectId, workspaceId, workspaceType },
757
+ );
758
+ }
743
759
  // If the workspace is not found,
744
760
  // we will not automatically redirect the user to the setup page as they will lose the URL
745
761
  // instead, we give them the option to:
@@ -810,6 +826,37 @@ export class EditorStore implements CommandRegistrar {
810
826
  onLeave(false);
811
827
  return;
812
828
  }
829
+ // At this point both the project and the workspace have been confirmed
830
+ // to exist on the server (the guards above bail out otherwise), so this
831
+ // is the authoritative "the user actually opened this workspace" moment.
832
+ // Record it in the recents cache so the workspace setup screen can offer
833
+ // it instantly next time.
834
+ // NOTE: patch-based workspaces are intentionally excluded from recents
835
+ // (they are not surfaced in the recents UI). Sandbox projects ARE
836
+ // included — opening one is still a meaningful "I worked here" signal,
837
+ // and surfacing it alongside other recents gives a faster one-click
838
+ // re-entry than waiting for the dedicated sandbox loader.
839
+ if (this.sdlcState.currentWorkspace.source === undefined) {
840
+ LegendStudioUserDataHelper.workspaceSetup_recordRecentProject(
841
+ this.applicationStore.userDataService,
842
+ {
843
+ projectId: this.sdlcState.currentProject.projectId,
844
+ name: this.sdlcState.currentProject.name,
845
+ description: this.sdlcState.currentProject.description,
846
+ webUrl: this.sdlcState.currentProject.webUrl,
847
+ tags: this.sdlcState.currentProject.tags,
848
+ },
849
+ );
850
+ LegendStudioUserDataHelper.workspaceSetup_recordRecentWorkspace(
851
+ this.applicationStore.userDataService,
852
+ {
853
+ projectId: this.sdlcState.currentProject.projectId,
854
+ workspaceId: this.sdlcState.currentWorkspace.workspaceId,
855
+ workspaceType: this.sdlcState.currentWorkspace.workspaceType,
856
+ },
857
+ );
858
+ }
859
+
813
860
  yield Promise.all([
814
861
  this.sdlcState.fetchCurrentRevision(
815
862
  projectId,
@@ -63,7 +63,9 @@ import {
63
63
  EqualTo,
64
64
  EqualToRelation,
65
65
  RelationElement,
66
+ RelationRowTestData,
66
67
  observe_RelationElement,
68
+ observe_RelationRowTestData,
67
69
  ModelStore,
68
70
  RelationElementsData,
69
71
  CORE_PURE_PATH,
@@ -723,7 +725,6 @@ export const createFunctionTest = async (
723
725
  assertion.id = DEFAULT_TEST_ASSERTION_ID;
724
726
  const expectedRelElement = new RelationElement();
725
727
  expectedRelElement.paths = [];
726
- expectedRelElement.rows = [];
727
728
  let inferredColumns: string[] = [];
728
729
  if (type.path === CORE_PURE_PATH.RELATION) {
729
730
  try {
@@ -746,6 +747,9 @@ export const createFunctionTest = async (
746
747
  }
747
748
  }
748
749
  expectedRelElement.columns = inferredColumns;
750
+ const emptyRow = observe_RelationRowTestData(new RelationRowTestData());
751
+ emptyRow.values = inferredColumns.map(() => '');
752
+ expectedRelElement.rows = [emptyRow];
749
753
  observe_RelationElement(expectedRelElement);
750
754
  assertion.expected = expectedRelElement;
751
755
  _assertion = assertion;
@@ -42,6 +42,7 @@ import {
42
42
  type WorkspaceType,
43
43
  } from '@finos/legend-server-sdlc';
44
44
  import { LEGEND_STUDIO_APP_EVENT } from '../../../__lib__/LegendStudioEvent.js';
45
+ import { LegendStudioUserDataHelper } from '../../../__lib__/LegendStudioUserDataHelper.js';
45
46
 
46
47
  export enum PROJECT_OVERVIEW_ACTIVITY_MODE {
47
48
  RELEASE = 'RELEASE',
@@ -155,6 +156,19 @@ export class ProjectOverviewState {
155
156
  this.projectWorkspaces = this.projectWorkspaces.filter(
156
157
  (w) => !areWorkspacesEquivalent(workspace, w),
157
158
  );
159
+ // Drop the deleted workspace from the recents cache so the workspace
160
+ // setup screen doesn't keep offering a dead link. Patch workspaces are
161
+ // never cached, so this is a no-op for them.
162
+ if (workspace.source === undefined) {
163
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentWorkspace(
164
+ this.editorStore.applicationStore.userDataService,
165
+ {
166
+ projectId: this.sdlcState.activeProject.projectId,
167
+ workspaceId: workspace.workspaceId,
168
+ workspaceType: workspace.workspaceType,
169
+ },
170
+ );
171
+ }
158
172
  // redirect to home page if current workspace is deleted
159
173
  if (
160
174
  areWorkspacesEquivalent(
@@ -25,6 +25,7 @@ import {
25
25
  import type { EditorStore } from '../EditorStore.js';
26
26
  import type { EditorSDLCState } from '../EditorSDLCState.js';
27
27
  import { LEGEND_STUDIO_APP_EVENT } from '../../../__lib__/LegendStudioEvent.js';
28
+ import { LegendStudioUserDataHelper } from '../../../__lib__/LegendStudioUserDataHelper.js';
28
29
  import {
29
30
  type GeneratorFn,
30
31
  type PlainObject,
@@ -402,6 +403,19 @@ export class WorkspaceReviewState {
402
403
  review.id,
403
404
  { message: `${review.title} [review]` },
404
405
  );
406
+ // Committing a review deletes the workspace on SDLC. Drop it from
407
+ // the recents cache so the workspace setup screen doesn't keep
408
+ // offering a dead link. Patch workspaces are never cached.
409
+ if (this.sdlcState.activePatch === undefined) {
410
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentWorkspace(
411
+ this.editorStore.applicationStore.userDataService,
412
+ {
413
+ projectId: this.sdlcState.activeProject.projectId,
414
+ workspaceId: this.sdlcState.activeWorkspace.workspaceId,
415
+ workspaceType: this.sdlcState.activeWorkspace.workspaceType,
416
+ },
417
+ );
418
+ }
405
419
  this.editorStore.applicationStore.alertService.setActionAlertInfo({
406
420
  message: 'Committed review successfully',
407
421
  prompt:
@@ -156,6 +156,13 @@ export class DevMetadataState {
156
156
  'Project Name required to push to dev mode',
157
157
  );
158
158
  this.pushState.inProgress();
159
+ LegendStudioTelemetryHelper.logEvent_DevMetadataPushLaunched(
160
+ this.editorStore.applicationStore.telemetryService,
161
+ this.editorStore.editorMode.getSourceInfo(),
162
+ currentProjectConfiguration.groupId,
163
+ currentProjectConfiguration.artifactId,
164
+ undefined,
165
+ );
159
166
  const result =
160
167
  (yield this.editorStore.graphManagerState.graphManager.pushToDevMetadata(
161
168
  currentProjectConfiguration.groupId,
@@ -165,7 +172,7 @@ export class DevMetadataState {
165
172
  this.editorStore.graphManagerState.graph,
166
173
  )) as DeployProjectResponse;
167
174
  this.result = result;
168
- LegendStudioTelemetryHelper.logEvent_DevMetadataPushLaunched(
175
+ LegendStudioTelemetryHelper.logEvent_DevMetadataPushSucceeded(
169
176
  this.editorStore.applicationStore.telemetryService,
170
177
  this.editorStore.editorMode.getSourceInfo(),
171
178
  currentProjectConfiguration.groupId,
@@ -45,6 +45,7 @@ import {
45
45
  ReviewApproval,
46
46
  } from '@finos/legend-server-sdlc';
47
47
  import { LEGEND_STUDIO_APP_EVENT } from '../../__lib__/LegendStudioEvent.js';
48
+ import { LegendStudioUserDataHelper } from '../../__lib__/LegendStudioUserDataHelper.js';
48
49
  import { DEFAULT_TAB_SIZE } from '@finos/legend-application';
49
50
  import type { Entity } from '@finos/legend-storage';
50
51
  import { EntityDiffViewState } from '../editor/editor-state/entity-diff-editor-state/EntityDiffViewState.js';
@@ -457,6 +458,20 @@ export class ProjectReviewerStore {
457
458
  { message: `${this.review.title} [review]` },
458
459
  )) as PlainObject<Review>,
459
460
  );
461
+ // Committing a review deletes its workspace on SDLC. Drop the
462
+ // matching entry from the local recents cache (no-op if it wasn't
463
+ // there, e.g., the reviewer isn't the workspace author). Patch
464
+ // workspaces are never cached.
465
+ if (this.patchReleaseVersionId === undefined) {
466
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentWorkspace(
467
+ this.editorStore.applicationStore.userDataService,
468
+ {
469
+ projectId: this.projectId,
470
+ workspaceId: this.review.workspaceId,
471
+ workspaceType: this.review.workspaceType,
472
+ },
473
+ );
474
+ }
460
475
  } catch (error) {
461
476
  assertErrorThrown(error);
462
477
  this.editorStore.applicationStore.logService.error(
@@ -51,6 +51,11 @@ import {
51
51
  ProjectConfigurationStatus,
52
52
  } from './ProjectConfigurationStatus.js';
53
53
  import { GraphManagerState } from '@finos/legend-graph';
54
+ import {
55
+ LegendStudioUserDataHelper,
56
+ type RecentProjectEntry,
57
+ type RecentWorkspaceEntry,
58
+ } from '../../__lib__/LegendStudioUserDataHelper.js';
54
59
 
55
60
  interface ImportProjectSuccessReport {
56
61
  projectId: string;
@@ -88,10 +93,15 @@ export class WorkspaceSetupStore {
88
93
  loadWorkspacesState = ActionState.create();
89
94
  createWorkspaceState = ActionState.create();
90
95
  showCreateWorkspaceModal = false;
91
- showAdvancedWorkspaceFilterOptions = false;
92
96
 
93
97
  graphManagerState: GraphManagerState;
94
98
 
99
+ // Cached recents to make re-opening a project/workspace instantaneous.
100
+ // NOTE: patch-based workspaces are intentionally excluded from this cache.
101
+ recentProjects: RecentProjectEntry[] = [];
102
+ recentWorkspaces: RecentWorkspaceEntry[] = [];
103
+ selectRecentProjectState = ActionState.create();
104
+
95
105
  constructor(
96
106
  applicationStore: LegendStudioApplicationStore,
97
107
  sdlcServerClient: SDLCServerClient,
@@ -104,7 +114,6 @@ export class WorkspaceSetupStore {
104
114
  showCreateProjectModal: observable,
105
115
  workspaces: observable,
106
116
  currentWorkspace: observable,
107
- showAdvancedWorkspaceFilterOptions: observable,
108
117
  loadSandboxState: observable,
109
118
  showCreateWorkspaceModal: observable,
110
119
  sandboxProject: observable,
@@ -113,14 +122,18 @@ export class WorkspaceSetupStore {
113
122
  enginePromise: observable,
114
123
  sandboxModal: observable,
115
124
  hasSandboxAccess: observable,
125
+ recentProjects: observable,
126
+ recentWorkspaces: observable,
116
127
  setShowCreateProjectModal: action,
117
128
  setShowCreateWorkspaceModal: action,
118
- setShowAdvancedWorkspaceFilterOptions: action,
119
129
  setImportProjectSuccessReport: action,
120
130
  setSandboxModal: action,
121
131
  changeWorkspace: action,
122
132
  resetProject: action,
123
133
  resetWorkspace: action,
134
+ removeRecentProject: action,
135
+ removeRecentWorkspace: action,
136
+ clearRecents: action,
124
137
  initialize: flow,
125
138
  loadProjects: flow,
126
139
  loadSandboxProject: flow,
@@ -130,6 +143,7 @@ export class WorkspaceSetupStore {
130
143
  createSandboxProject: flow,
131
144
  createWorkspace: flow,
132
145
  initializeEngine: flow,
146
+ selectRecentProject: flow,
133
147
  });
134
148
 
135
149
  this.applicationStore = applicationStore;
@@ -138,6 +152,14 @@ export class WorkspaceSetupStore {
138
152
  applicationStore.pluginManager,
139
153
  applicationStore.logService,
140
154
  );
155
+ this.recentProjects =
156
+ LegendStudioUserDataHelper.workspaceSetup_getRecentProjects(
157
+ applicationStore.userDataService,
158
+ );
159
+ this.recentWorkspaces =
160
+ LegendStudioUserDataHelper.workspaceSetup_getRecentWorkspaces(
161
+ applicationStore.userDataService,
162
+ );
141
163
  if (this.supportsCreatingSandboxProject) {
142
164
  flowResult(this.initializeEngine()).catch(
143
165
  applicationStore.alertUnhandledError,
@@ -158,10 +180,6 @@ export class WorkspaceSetupStore {
158
180
  this.showCreateWorkspaceModal = val;
159
181
  }
160
182
 
161
- setShowAdvancedWorkspaceFilterOptions(val: boolean): void {
162
- this.showAdvancedWorkspaceFilterOptions = val;
163
- }
164
-
165
183
  setImportProjectSuccessReport(
166
184
  importProjectSuccessReport: ImportProjectSuccessReport | undefined,
167
185
  ): void {
@@ -197,6 +215,73 @@ export class WorkspaceSetupStore {
197
215
  this.sandboxModal = val;
198
216
  }
199
217
 
218
+ // --- Recents -------------------------------------------------------------
219
+ // NOTE: writes to the recents cache happen from the editor (see
220
+ // `EditorStore.initialize`) at the moment a workspace is actually opened.
221
+ // This store only reads the cache (to seed dropdowns) and prunes entries
222
+ // that are discovered to be stale.
223
+
224
+ removeRecentProject(projectId: string): void {
225
+ const updated =
226
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentProject(
227
+ this.applicationStore.userDataService,
228
+ projectId,
229
+ );
230
+ this.recentProjects = updated.projects;
231
+ this.recentWorkspaces = updated.workspaces;
232
+ }
233
+
234
+ removeRecentWorkspace(entry: {
235
+ projectId: string;
236
+ workspaceId: string;
237
+ workspaceType: WorkspaceType;
238
+ }): void {
239
+ this.recentWorkspaces =
240
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentWorkspace(
241
+ this.applicationStore.userDataService,
242
+ entry,
243
+ );
244
+ }
245
+
246
+ clearRecents(): void {
247
+ LegendStudioUserDataHelper.workspaceSetup_clearRecents(
248
+ this.applicationStore.userDataService,
249
+ );
250
+ this.recentProjects = [];
251
+ this.recentWorkspaces = [];
252
+ }
253
+
254
+ /**
255
+ * Fetches a project by id (used when the user picks a cached "recent"
256
+ * project that may not be in the current search results) and switches to
257
+ * it. If the project no longer exists, the entry is pruned from recents.
258
+ *
259
+ * NOTE: we deliberately don't short-circuit using the cached recent entry
260
+ * here. Going through `getProject` keeps the prune-on-404 path intact, and
261
+ * the cached metadata is already used elsewhere to make the UI feel fast
262
+ * (dropdown stubs in `WorkspaceSetup.tsx` and tile labels in
263
+ * `RecentWorkspacesPanel.tsx`).
264
+ */
265
+ *selectRecentProject(projectId: string): GeneratorFn<void> {
266
+ this.selectRecentProjectState.inProgress();
267
+ try {
268
+ const project = Project.serialization.fromJson(
269
+ (yield this.sdlcServerClient.getProject(
270
+ projectId,
271
+ )) as PlainObject<Project>,
272
+ );
273
+ yield flowResult(this.changeProject(project));
274
+ this.selectRecentProjectState.pass();
275
+ } catch (error) {
276
+ assertErrorThrown(error);
277
+ this.removeRecentProject(projectId);
278
+ this.applicationStore.notificationService.notifyWarning(
279
+ `Recent project could not be opened and was removed from recents`,
280
+ );
281
+ this.selectRecentProjectState.fail();
282
+ }
283
+ }
284
+
200
285
  *createSandboxProject(): GeneratorFn<void> {
201
286
  try {
202
287
  if (!this.hasSandboxAccess) {
@@ -218,6 +303,12 @@ export class WorkspaceSetupStore {
218
303
  message: `Sandbox project ${sandboxProject.projectId} created. Creating default workspace...`,
219
304
  showLoading: true,
220
305
  });
306
+ // Invalidate the cached sandbox info so loadSandboxProject re-fetches
307
+ // and persists the newly-created project id instead of reusing the
308
+ // stale "no sandbox yet" cache entry.
309
+ LegendStudioUserDataHelper.workspaceSetup_clearSandboxInfo(
310
+ this.applicationStore.userDataService,
311
+ );
221
312
  yield flowResult(this.loadSandboxProject());
222
313
  const sandbox = guaranteeType(
223
314
  this.sandboxProject,
@@ -277,6 +368,7 @@ export class WorkspaceSetupStore {
277
368
  )) as PlainObject<Project>,
278
369
  );
279
370
  } catch {
371
+ this.removeRecentProject(projectId);
280
372
  this.applicationStore.navigationService.navigator.updateCurrentLocation(
281
373
  generateSetupRoute(undefined, undefined),
282
374
  );
@@ -373,10 +465,58 @@ export class WorkspaceSetupStore {
373
465
  if (this.enginePromise) {
374
466
  yield this.enginePromise;
375
467
  }
468
+
469
+ const userId = this.sdlcServerClient.currentUser?.userId;
470
+
471
+ // Fast path — if we have a recent, user-matching cache entry, use it to
472
+ // avoid the `userHasPrototypeProjectAccess` graph manager call and the
473
+ // sandbox-tag project search. We still hit SDLC once to confirm the
474
+ // cached projectId is alive, but `getProject(id)` is cheaper than the
475
+ // tagged search and self-invalidates on 404.
476
+ if (userId) {
477
+ const cached =
478
+ LegendStudioUserDataHelper.workspaceSetup_getCachedSandboxInfo(
479
+ this.applicationStore.userDataService,
480
+ userId,
481
+ );
482
+ if (cached) {
483
+ this.hasSandboxAccess = cached.hasAccess;
484
+ if (!cached.hasAccess) {
485
+ // No access, no project — nothing else to do.
486
+ this.sandboxProject = true;
487
+ this.loadSandboxState.pass();
488
+ return;
489
+ }
490
+ if (cached.projectId) {
491
+ try {
492
+ this.sandboxProject = Project.serialization.fromJson(
493
+ (yield this.sdlcServerClient.getProject(
494
+ cached.projectId,
495
+ )) as PlainObject<Project>,
496
+ );
497
+ this.loadSandboxState.pass();
498
+ return;
499
+ } catch {
500
+ // Cached sandbox project no longer exists on the server; drop
501
+ // the cache and fall through to the full refresh.
502
+ LegendStudioUserDataHelper.workspaceSetup_clearSandboxInfo(
503
+ this.applicationStore.userDataService,
504
+ );
505
+ }
506
+ } else {
507
+ // User has access but hasn't created a sandbox yet.
508
+ this.sandboxProject = true;
509
+ this.loadSandboxState.pass();
510
+ return;
511
+ }
512
+ }
513
+ }
514
+
515
+ // Slow path — original flow.
376
516
  const sandboxProject = (
377
517
  (yield this.sdlcServerClient.getProjects(
378
518
  undefined,
379
- this.sdlcServerClient.currentUser?.userId,
519
+ userId,
380
520
  [SANDBOX_SDLC_TAG],
381
521
  1,
382
522
  )) as PlainObject<Project>[]
@@ -384,7 +524,7 @@ export class WorkspaceSetupStore {
384
524
  if (this.hasSandboxAccess === undefined) {
385
525
  this.hasSandboxAccess =
386
526
  (yield this.graphManagerState.graphManager.userHasPrototypeProjectAccess(
387
- this.sdlcServerClient.currentUser?.userId ?? '',
527
+ userId ?? '',
388
528
  )) as boolean;
389
529
  }
390
530
  this.sandboxProject = true;
@@ -395,6 +535,23 @@ export class WorkspaceSetupStore {
395
535
  } else if (sandboxProject.length === 1) {
396
536
  this.sandboxProject = guaranteeNonNullable(sandboxProject[0]);
397
537
  }
538
+
539
+ // Persist the fresh result for next time. We only cache when we have
540
+ // a userId (cache is scoped per user); anonymous sessions skip this.
541
+ if (userId) {
542
+ LegendStudioUserDataHelper.workspaceSetup_recordSandboxInfo(
543
+ this.applicationStore.userDataService,
544
+ {
545
+ userId,
546
+ hasAccess: this.hasSandboxAccess,
547
+ projectId:
548
+ this.sandboxProject instanceof Project
549
+ ? this.sandboxProject.projectId
550
+ : undefined,
551
+ },
552
+ );
553
+ }
554
+
398
555
  this.loadSandboxState.pass();
399
556
  } catch (error) {
400
557
  this.sandboxProject = true;
@@ -513,6 +670,12 @@ export class WorkspaceSetupStore {
513
670
  if (matchingWorkspace) {
514
671
  this.changeWorkspace(matchingWorkspace);
515
672
  } else {
673
+ // Workspace no longer exists — prune from recents.
674
+ this.removeRecentWorkspace({
675
+ projectId: project.projectId,
676
+ workspaceId: workspaceInfo.workspaceId,
677
+ workspaceType: workspaceInfo.workspaceType,
678
+ });
516
679
  this.applicationStore.navigationService.navigator.updateCurrentLocation(
517
680
  generateSetupRoute(project.projectId, undefined),
518
681
  );
package/tsconfig.json CHANGED
@@ -374,6 +374,7 @@
374
374
  "./src/components/workspace-setup/CreateProjectModal.tsx",
375
375
  "./src/components/workspace-setup/CreateWorkspaceModal.tsx",
376
376
  "./src/components/workspace-setup/ProjectSelectorUtils.tsx",
377
+ "./src/components/workspace-setup/RecentWorkspacesPanel.tsx",
377
378
  "./src/components/workspace-setup/WorkspaceSelectorUtils.tsx",
378
379
  "./src/components/workspace-setup/WorkspaceSetup.tsx"
379
380
  ],