@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
@@ -1,5 +1,12 @@
1
1
  import type { UserDataService } from '@finos/legend-application';
2
- import { returnUndefOnError } from '@finos/legend-shared';
2
+ import {
3
+ type PlainObject,
4
+ SerializationFactory,
5
+ returnUndefOnError,
6
+ usingModelSchema,
7
+ } from '@finos/legend-shared';
8
+ import { WorkspaceType } from '@finos/legend-server-sdlc';
9
+ import { createModelSchema, list, optional, primitive } from 'serializr';
3
10
 
4
11
  /**
5
12
  * Copyright (c) 2020-present, Goldman Sachs
@@ -29,6 +36,139 @@ export enum LEGEND_STUDIO_USER_DATA_KEY {
29
36
  // drop the toggle button in the tab header, and retarget the SCSS
30
37
  // `.database-editor--light` block at the framework's color-theme tokens.
31
38
  DATABASE_EDITOR_THEME = 'studio-editor.database-editor.theme',
39
+ // Recently-opened projects and (non-patch) workspaces shown on the
40
+ // workspace setup screen to speed up re-opening common work.
41
+ WORKSPACE_SETUP_RECENTS = 'studio-editor.workspace-setup.recents',
42
+ // Per-user cache of the sandbox-access boolean + the sandbox project id,
43
+ // so the workspace setup screen can render the sandbox UI without waiting
44
+ // on the `userHasPrototypeProjectAccess` graph manager call AND a
45
+ // sandbox-tag project search on every mount. Revalidated against SDLC in
46
+ // the background; invalidated automatically on 404 or after the TTL.
47
+ WORKSPACE_SETUP_SANDBOX_INFO = 'studio-editor.workspace-setup.sandboxInfo',
48
+ }
49
+
50
+ // --- Workspace setup recents -------------------------------------------------
51
+
52
+ const WORKSPACE_SETUP_RECENTS_VERSION = 1;
53
+ const MAX_RECENT_PROJECTS = 10;
54
+ const MAX_RECENT_WORKSPACES = 20;
55
+
56
+ export class RecentProjectEntry {
57
+ projectId!: string;
58
+ name!: string;
59
+ description!: string;
60
+ webUrl!: string;
61
+ tags!: string[];
62
+ lastOpenedAt!: number;
63
+
64
+ static readonly serialization = new SerializationFactory(
65
+ createModelSchema(RecentProjectEntry, {
66
+ description: primitive(),
67
+ lastOpenedAt: primitive(),
68
+ name: primitive(),
69
+ projectId: primitive(),
70
+ tags: list(primitive()),
71
+ webUrl: primitive(),
72
+ }),
73
+ );
74
+ }
75
+
76
+ export class RecentWorkspaceEntry {
77
+ projectId!: string;
78
+ workspaceId!: string;
79
+ workspaceType!: WorkspaceType;
80
+ lastOpenedAt!: number;
81
+
82
+ static readonly serialization = new SerializationFactory(
83
+ createModelSchema(RecentWorkspaceEntry, {
84
+ lastOpenedAt: primitive(),
85
+ projectId: primitive(),
86
+ workspaceId: primitive(),
87
+ // WorkspaceType is a string enum; stored/loaded as a plain string and
88
+ // validated when the entry is consumed.
89
+ workspaceType: primitive(),
90
+ }),
91
+ );
92
+ }
93
+
94
+ export class WorkspaceSetupRecents {
95
+ version: number = WORKSPACE_SETUP_RECENTS_VERSION;
96
+ projects: RecentProjectEntry[] = [];
97
+ workspaces: RecentWorkspaceEntry[] = [];
98
+
99
+ static readonly serialization = new SerializationFactory(
100
+ createModelSchema(WorkspaceSetupRecents, {
101
+ projects: list(usingModelSchema(RecentProjectEntry.serialization.schema)),
102
+ version: primitive(),
103
+ workspaces: list(
104
+ usingModelSchema(RecentWorkspaceEntry.serialization.schema),
105
+ ),
106
+ }),
107
+ );
108
+ }
109
+
110
+ const isValidWorkspaceType = (v: unknown): v is WorkspaceType =>
111
+ v === WorkspaceType.USER || v === WorkspaceType.GROUP;
112
+
113
+ const emptyRecents = (): WorkspaceSetupRecents => new WorkspaceSetupRecents();
114
+
115
+ const readRecents = (service: UserDataService): WorkspaceSetupRecents => {
116
+ const raw = returnUndefOnError(() =>
117
+ service.getObjectValue(LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_RECENTS),
118
+ );
119
+ if (!raw) {
120
+ return emptyRecents();
121
+ }
122
+ const parsed = returnUndefOnError(() =>
123
+ WorkspaceSetupRecents.serialization.fromJson(
124
+ raw as PlainObject<WorkspaceSetupRecents>,
125
+ ),
126
+ );
127
+ if (!parsed) {
128
+ return emptyRecents();
129
+ }
130
+ // Defensive post-checks: drop entries with invalid enum values and enforce
131
+ // the LRU caps in case the persisted blob was tampered with.
132
+ parsed.workspaces = parsed.workspaces
133
+ .filter((w) => isValidWorkspaceType(w.workspaceType))
134
+ .slice(0, MAX_RECENT_WORKSPACES);
135
+ parsed.projects = parsed.projects.slice(0, MAX_RECENT_PROJECTS);
136
+ return parsed;
137
+ };
138
+
139
+ const writeRecents = (
140
+ service: UserDataService,
141
+ recents: WorkspaceSetupRecents,
142
+ ): void => {
143
+ service.persistValue(
144
+ LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_RECENTS,
145
+ WorkspaceSetupRecents.serialization.toJson(recents),
146
+ );
147
+ };
148
+
149
+ // --- Cached sandbox info -----------------------------------------------------
150
+
151
+ // Sandbox access and the sandbox project id rarely change for a given user,
152
+ // but they're costly to look up on every setup mount (one graph manager call
153
+ // + one tagged-project search). Cache the result for a day and revalidate
154
+ // in the background on the fast path.
155
+ const SANDBOX_INFO_TTL_MS = 24 * 60 * 60 * 1000;
156
+
157
+ export class CachedSandboxInfo {
158
+ userId!: string;
159
+ hasAccess!: boolean;
160
+ /** undefined when the user has access but hasn't created a sandbox yet. */
161
+ projectId?: string | undefined;
162
+ fetchedAt!: number;
163
+
164
+ static readonly serialization = new SerializationFactory(
165
+ createModelSchema(CachedSandboxInfo, {
166
+ fetchedAt: primitive(),
167
+ hasAccess: primitive(),
168
+ projectId: optional(primitive()),
169
+ userId: primitive(),
170
+ }),
171
+ );
32
172
  }
33
173
 
34
174
  export class LegendStudioUserDataHelper {
@@ -70,4 +210,172 @@ export class LegendStudioUserDataHelper {
70
210
  val,
71
211
  );
72
212
  }
213
+
214
+ // --- Workspace setup recents ---------------------------------------------
215
+
216
+ static workspaceSetup_getRecentProjects(
217
+ service: UserDataService,
218
+ ): RecentProjectEntry[] {
219
+ return readRecents(service).projects;
220
+ }
221
+
222
+ static workspaceSetup_getRecentWorkspaces(
223
+ service: UserDataService,
224
+ ): RecentWorkspaceEntry[] {
225
+ return readRecents(service).workspaces;
226
+ }
227
+
228
+ static workspaceSetup_recordRecentProject(
229
+ service: UserDataService,
230
+ entry: {
231
+ projectId: string;
232
+ name: string;
233
+ description: string;
234
+ webUrl: string;
235
+ tags: string[];
236
+ },
237
+ ): RecentProjectEntry[] {
238
+ const recents = readRecents(service);
239
+ const next = new RecentProjectEntry();
240
+ next.projectId = entry.projectId;
241
+ next.name = entry.name;
242
+ next.description = entry.description;
243
+ next.webUrl = entry.webUrl;
244
+ next.tags = entry.tags;
245
+ next.lastOpenedAt = Date.now();
246
+ recents.projects = [
247
+ next,
248
+ ...recents.projects.filter((p) => p.projectId !== entry.projectId),
249
+ ].slice(0, MAX_RECENT_PROJECTS);
250
+ writeRecents(service, recents);
251
+ return recents.projects;
252
+ }
253
+
254
+ static workspaceSetup_recordRecentWorkspace(
255
+ service: UserDataService,
256
+ entry: {
257
+ projectId: string;
258
+ workspaceId: string;
259
+ workspaceType: WorkspaceType;
260
+ },
261
+ ): RecentWorkspaceEntry[] {
262
+ const recents = readRecents(service);
263
+ const next = new RecentWorkspaceEntry();
264
+ next.projectId = entry.projectId;
265
+ next.workspaceId = entry.workspaceId;
266
+ next.workspaceType = entry.workspaceType;
267
+ next.lastOpenedAt = Date.now();
268
+ recents.workspaces = [
269
+ next,
270
+ ...recents.workspaces.filter(
271
+ (w) =>
272
+ !(
273
+ w.projectId === entry.projectId &&
274
+ w.workspaceId === entry.workspaceId &&
275
+ w.workspaceType === entry.workspaceType
276
+ ),
277
+ ),
278
+ ].slice(0, MAX_RECENT_WORKSPACES);
279
+ writeRecents(service, recents);
280
+ return recents.workspaces;
281
+ }
282
+
283
+ static workspaceSetup_removeRecentProject(
284
+ service: UserDataService,
285
+ projectId: string,
286
+ ): WorkspaceSetupRecents {
287
+ const recents = readRecents(service);
288
+ recents.projects = recents.projects.filter(
289
+ (p) => p.projectId !== projectId,
290
+ );
291
+ recents.workspaces = recents.workspaces.filter(
292
+ (w) => w.projectId !== projectId,
293
+ );
294
+ writeRecents(service, recents);
295
+ return recents;
296
+ }
297
+
298
+ static workspaceSetup_removeRecentWorkspace(
299
+ service: UserDataService,
300
+ entry: {
301
+ projectId: string;
302
+ workspaceId: string;
303
+ workspaceType: WorkspaceType;
304
+ },
305
+ ): RecentWorkspaceEntry[] {
306
+ const recents = readRecents(service);
307
+ recents.workspaces = recents.workspaces.filter(
308
+ (w) =>
309
+ !(
310
+ w.projectId === entry.projectId &&
311
+ w.workspaceId === entry.workspaceId &&
312
+ w.workspaceType === entry.workspaceType
313
+ ),
314
+ );
315
+ writeRecents(service, recents);
316
+ return recents.workspaces;
317
+ }
318
+
319
+ static workspaceSetup_clearRecents(service: UserDataService): void {
320
+ writeRecents(service, emptyRecents());
321
+ }
322
+
323
+ // --- Cached sandbox info -------------------------------------------------
324
+
325
+ static workspaceSetup_getCachedSandboxInfo(
326
+ service: UserDataService,
327
+ currentUserId: string,
328
+ ): CachedSandboxInfo | undefined {
329
+ const raw = returnUndefOnError(() =>
330
+ service.getObjectValue(
331
+ LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_SANDBOX_INFO,
332
+ ),
333
+ );
334
+ if (!raw) {
335
+ return undefined;
336
+ }
337
+ const parsed = returnUndefOnError(() =>
338
+ CachedSandboxInfo.serialization.fromJson(
339
+ raw as PlainObject<CachedSandboxInfo>,
340
+ ),
341
+ );
342
+ if (!parsed) {
343
+ return undefined;
344
+ }
345
+ // Discard entries that don't belong to the user looking at the screen
346
+ // (e.g., after a user switch on a shared machine) or that have aged out.
347
+ if (parsed.userId !== currentUserId) {
348
+ return undefined;
349
+ }
350
+ if (Date.now() - parsed.fetchedAt > SANDBOX_INFO_TTL_MS) {
351
+ return undefined;
352
+ }
353
+ return parsed;
354
+ }
355
+
356
+ static workspaceSetup_recordSandboxInfo(
357
+ service: UserDataService,
358
+ info: {
359
+ userId: string;
360
+ hasAccess: boolean;
361
+ projectId?: string | undefined;
362
+ },
363
+ ): void {
364
+ const entry = new CachedSandboxInfo();
365
+ entry.userId = info.userId;
366
+ entry.hasAccess = info.hasAccess;
367
+ entry.projectId = info.projectId;
368
+ entry.fetchedAt = Date.now();
369
+ service.persistValue(
370
+ LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_SANDBOX_INFO,
371
+ CachedSandboxInfo.serialization.toJson(entry),
372
+ );
373
+ }
374
+
375
+ static workspaceSetup_clearSandboxInfo(service: UserDataService): void {
376
+ service.persistValue(
377
+ LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_SANDBOX_INFO,
378
+ undefined,
379
+ );
380
+ }
73
381
  }
@@ -400,6 +400,7 @@ export function renderEmbeddedDataEditor(
400
400
  dataState={embeddedDataState}
401
401
  isReadOnly={isReadOnly}
402
402
  isSharedData={isSharedData}
403
+ hideColumnDefinitions={true}
403
404
  />
404
405
  );
405
406
  } else if (embeddedDataState instanceof DataElementReferenceState) {
@@ -426,7 +426,8 @@ export const RelationElementEditor = observer(
426
426
  </div>
427
427
  </div>
428
428
  ) : null}
429
- {embeddedData.rows.length === 0 ? (
429
+ {embeddedData.rows.length === 0 &&
430
+ embeddedData.columns.length === 0 ? (
430
431
  <div className="relation-test-data-editor__empty-data">
431
432
  <div className="relation-test-data-editor__empty-text">
432
433
  No test data rows. Click &quot;+&quot; below to start entering
@@ -440,13 +441,20 @@ export const RelationElementEditor = observer(
440
441
  <tr>
441
442
  {embeddedData.columns.map((column, columnIndex) => (
442
443
  <th
443
- key={column}
444
+ key={`col-${guaranteeNonNullable(columnIndex)}`}
444
445
  className="relation-test-data-editor__th"
445
446
  >
446
447
  <div className="relation-test-data-editor__th__inner">
447
- <span className="relation-test-data-editor__th__label">
448
- {column}
449
- </span>
448
+ <input
449
+ className="relation-test-data-editor__th__label relation-test-data-editor__th__label--editable"
450
+ type="text"
451
+ value={column}
452
+ onChange={(e) =>
453
+ updateColumn(columnIndex, e.target.value)
454
+ }
455
+ disabled={!canEditColumns}
456
+ title={column}
457
+ />
450
458
  <button
451
459
  className="relation-test-data-editor__th__delete"
452
460
  onClick={() => removeColumn(columnIndex)}
@@ -661,6 +661,7 @@ const SampleValuesEditorModal = observer(
661
661
  accessPointState.relationElementState
662
662
  }
663
663
  isReadOnly={isReadOnly}
664
+ hideColumnDefinitions={true}
664
665
  />
665
666
  </div>
666
667
  </Tooltip>
@@ -595,6 +595,7 @@ const EqualToRelationAsssertionEditor = observer(
595
595
  equalToRelationAssertionState.expectedRelationElementState
596
596
  }
597
597
  isReadOnly={isReadOnly}
598
+ hideColumnDefinitions={true}
598
599
  />
599
600
  );
600
601
  },
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Copyright (c) 2020-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { observer } from 'mobx-react-lite';
18
+ import {
19
+ FolderIcon,
20
+ HistoryIcon,
21
+ TimesIcon,
22
+ UserIcon,
23
+ UsersIcon,
24
+ } from '@finos/legend-art';
25
+ import { WorkspaceType } from '@finos/legend-server-sdlc';
26
+ import type { WorkspaceSetupStore } from '../../stores/workspace-setup/WorkspaceSetupStore.js';
27
+ import { generateEditorRoute } from '../../__lib__/LegendStudioNavigation.js';
28
+
29
+ const MAX_TILES = 6;
30
+
31
+ const formatRelativeTime = (timestamp: number): string => {
32
+ const diffMs = Date.now() - timestamp;
33
+ if (diffMs < 0) {
34
+ return 'just now';
35
+ }
36
+ const minutes = Math.floor(diffMs / 60_000);
37
+ if (minutes < 1) {
38
+ return 'just now';
39
+ }
40
+ if (minutes < 60) {
41
+ return `${minutes}m ago`;
42
+ }
43
+ const hours = Math.floor(minutes / 60);
44
+ if (hours < 24) {
45
+ return `${hours}h ago`;
46
+ }
47
+ const days = Math.floor(hours / 24);
48
+ if (days < 7) {
49
+ return `${days}d ago`;
50
+ }
51
+ const weeks = Math.floor(days / 7);
52
+ if (weeks < 4) {
53
+ return `${weeks}w ago`;
54
+ }
55
+ const months = Math.floor(days / 30);
56
+ if (months < 12) {
57
+ return `${months}mo ago`;
58
+ }
59
+ return `${Math.floor(days / 365)}y ago`;
60
+ };
61
+
62
+ export const RecentWorkspacesPanel = observer(
63
+ (props: { setupStore: WorkspaceSetupStore }) => {
64
+ const { setupStore } = props;
65
+ const applicationStore = setupStore.applicationStore;
66
+
67
+ // Recents are stored in LRU order (most-recent first). Show the top N
68
+ // workspaces; look up each one's cached project entry for richer tile
69
+ // metadata (name, description tooltip, tag badges).
70
+ const tiles = setupStore.recentWorkspaces.slice(0, MAX_TILES);
71
+ if (tiles.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ const projectById = new Map(
76
+ setupStore.recentProjects.map((p) => [p.projectId, p]),
77
+ );
78
+
79
+ const openWorkspace = (
80
+ projectId: string,
81
+ workspaceId: string,
82
+ workspaceType: WorkspaceType,
83
+ ): void => {
84
+ applicationStore.navigationService.navigator.goToLocation(
85
+ generateEditorRoute(projectId, undefined, workspaceId, workspaceType),
86
+ );
87
+ };
88
+
89
+ return (
90
+ <div className="workspace-setup__recents">
91
+ <div className="workspace-setup__recents__header">
92
+ <div className="workspace-setup__recents__header__title">
93
+ <HistoryIcon />
94
+ <span>Recent workspaces</span>
95
+ </div>
96
+ </div>
97
+ <div className="workspace-setup__recents__grid">
98
+ {tiles.map((entry) => {
99
+ const cachedProject = projectById.get(entry.projectId);
100
+ const projectName = cachedProject?.name ?? entry.projectId;
101
+ const projectDescription = cachedProject?.description ?? '';
102
+ const projectTags = cachedProject?.tags ?? [];
103
+ const tileTitle =
104
+ projectDescription.trim().length > 0
105
+ ? `${projectName} / ${entry.workspaceId}\n${projectDescription}`
106
+ : `Open ${projectName} / ${entry.workspaceId}`;
107
+ const key = `${entry.projectId}::${entry.workspaceType}::${entry.workspaceId}`;
108
+ const handleRemove = (
109
+ event: React.MouseEvent<HTMLButtonElement>,
110
+ ): void => {
111
+ event.stopPropagation();
112
+ setupStore.removeRecentWorkspace({
113
+ projectId: entry.projectId,
114
+ workspaceId: entry.workspaceId,
115
+ workspaceType: entry.workspaceType,
116
+ });
117
+ };
118
+ return (
119
+ <button
120
+ key={key}
121
+ type="button"
122
+ className="workspace-setup__recents__tile"
123
+ title={tileTitle}
124
+ onClick={() =>
125
+ openWorkspace(
126
+ entry.projectId,
127
+ entry.workspaceId,
128
+ entry.workspaceType,
129
+ )
130
+ }
131
+ >
132
+ <button
133
+ type="button"
134
+ tabIndex={-1}
135
+ className="workspace-setup__recents__tile__remove"
136
+ title="Remove from recents"
137
+ onClick={handleRemove}
138
+ >
139
+ <TimesIcon />
140
+ </button>
141
+ <div className="workspace-setup__recents__tile__project">
142
+ <FolderIcon />
143
+ <span className="workspace-setup__recents__tile__project__name">
144
+ {projectName}
145
+ </span>
146
+ </div>
147
+ <div className="workspace-setup__recents__tile__workspace">
148
+ {entry.workspaceType === WorkspaceType.GROUP ? (
149
+ <UsersIcon />
150
+ ) : (
151
+ <UserIcon />
152
+ )}
153
+ <span className="workspace-setup__recents__tile__workspace__name">
154
+ {entry.workspaceId}
155
+ </span>
156
+ </div>
157
+ <div className="workspace-setup__recents__tile__meta">
158
+ {projectTags.length > 0 && (
159
+ <div className="workspace-setup__recents__tile__tags">
160
+ {projectTags.slice(0, 2).map((tag) => (
161
+ <span
162
+ key={tag}
163
+ className="workspace-setup__recents__tile__tag"
164
+ >
165
+ {tag}
166
+ </span>
167
+ ))}
168
+ </div>
169
+ )}
170
+ <div className="workspace-setup__recents__tile__time">
171
+ {formatRelativeTime(entry.lastOpenedAt)}
172
+ </div>
173
+ </div>
174
+ </button>
175
+ );
176
+ })}
177
+ </div>
178
+ </div>
179
+ );
180
+ },
181
+ );