-
Notifications
You must be signed in to change notification settings - Fork 10.4k
Expand file tree
/
Copy pathRecentProjectsStrip.tsx
More file actions
2299 lines (2214 loc) · 90.9 KB
/
Copy pathRecentProjectsStrip.tsx
File metadata and controls
2299 lines (2214 loc) · 90.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Horizontal "Recent projects" rail for the Home view.
//
// Mirrors the strip Lovart shows under its hero: a small set of
// recent project cards with a "View all" link that switches to the
// full Projects view. We keep the data shape narrow (Project[] +
// onOpen / onViewAll) so the strip can be reused later by other
// surfaces (e.g. an in-project quick-switcher pane).
import type { CSSProperties } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { Dialog, DialogDescription, DialogFooter, DialogTitle } from '@open-design/components';
const MOVE_CONFIRM_SKIP_KEY = 'od.projects.moveConfirmSkip';
import { useT } from '../i18n';
import { fetchProjectFiles, fetchProjectFileText } from '../providers/registry';
import type { DesignSystemSummary, Project, ProjectDisplayStatus, ProjectFile } from '../types';
import { Icon } from './Icon';
import { InviteDialog } from './InviteDialog';
import { STATUS_LABEL_KEYS } from './DesignsTab';
import { isDesignSystemProject, isPublishedDesignSystemProject } from './design-system-project';
import type { SharedProjectPredicate } from '../collab/all-projects-list';
import { useTeamMembers } from '../collab/useTeamMembers';
import {
notifyTeamProjectsChanged,
useWorkspaceBilling,
useWorkspaceContext,
} from '../collab/useWorkspaceContext';
import {
canAccessWorkspaceInviteFlow,
resolveWorkspaceInviteTarget,
workspaceUpgradeUrl,
} from './EntryNavRail';
import { moveWorkspaceProject, workspaceProjectMoveErrorCode } from '../state/projects';
import { workspaceContextHasTeamIdentity } from '@open-design/contracts';
import { useWorkspaceInvalidation } from '../collab/workspace-events';
import {
THUMBNAIL_OVERSCAN_MARGIN,
resumeThumbnailLoads,
suspendThumbnailLoads,
useThumbnailLoadSlot,
} from '../lib/thumbnail-load-gate';
import {
getProjectCoverSnapshot,
invalidateProjectCoverSnapshots,
projectCoverSnapshotKey,
setProjectCoverSnapshot,
} from '../lib/project-cover-cache';
import { useInView } from './plugins-home/useInView';
/** Which project space this strip renders. Drives the per-card 共享 badge
* (hidden in the all-shared team space) and the "{creator}创建" line: 'recent'
* = home's mixed private/shared, 'drafts' = the member's own private list,
* 'team' = the全部项目 grid where every card is a team-shared project. */
export type SpaceKind = 'recent' | 'drafts' | 'team';
import {
coverFromProjectFile,
projectCoverUrl,
selectProjectFileCover,
type ProjectCoverOverride,
} from './project-cover';
interface Props {
projects: Project[];
/** Used only to show a "Published" status for design-system projects whose
* backing system is published (independent of the project's run status). */
designSystems?: DesignSystemSummary[];
/** Retained for call-site compatibility; the strip skips rendering
* while the list is loading so we never need a loading state. */
loading?: boolean;
/** Full-page project grids render their own title + controls. The Home strip
* omits this and keeps the compact "最近项目 / 查看全部" header. */
heading?: string;
description?: string;
/** Return false when opening failed and the grid stayed mounted, so aborted
* background cover work can resume after the foreground attempt finishes. */
onOpen: (id: string) => boolean | void | Promise<boolean | void>;
onViewAll?: () => void;
onDelete?: (id: string) => Promise<boolean | void> | boolean | void;
onDuplicate?: (id: string) => Promise<void> | void;
onRename?: (id: string, name: string) => void;
limit?: number;
/** The one shared-state answer for a card: true → 共享 badge + "已在团队空间",
* and the card cannot be re-shared. Owned by the caller, because the SAME
* answer decides which of the 全部项目 / 草稿 grids the project belongs to —
* see {@link createSharedProjectPredicate}. This strip must not re-derive it;
* a strip-local optimistic set is exactly how the badge and the grids drifted
* apart. Defaults to "nothing is shared" when a caller has no sharing surface. */
isSharedProject?: SharedProjectPredicate;
/** Reported after a successful share/unshare so the caller can fold the change
* into its optimistic layer before the team-projects poll catches up. */
onProjectShared?: (projectId: string) => void;
onProjectUnshared?: (projectId: string) => void;
/** Which space this strip renders (see {@link SpaceKind}). Defaults to
* 'recent' (home). 'team' hides the per-card 共享 badge since every card
* there is already a team-shared project. */
space?: SpaceKind;
/** projectId → the sharing member's workspaceMemberId, for team-shared
* projects (from the team hub). Used to resolve the creator name against the
* member directory; a project absent from this map is a local project owned
* by the current member ("我创建"). */
projectOwnerMemberIds?: ReadonlyMap<string, string>;
/** Project currently being materialized before it can open (a member's
* first click on a team-shared card triggers a full content pull). The
* card shows a spinner overlay and further clicks are ignored — without
* this the pull looked like a dead click for its whole duration. */
openingProjectId?: string | null;
collaborationEnabled?: boolean;
canAssignInviteRoles?: boolean;
canManageProjectCollection?: boolean;
/** Whether this mounted strip is visible. EntryShell keeps Home mounted while
* other views are active, so hidden strips must not occupy browser connection
* slots with background cover probes. */
isActive?: boolean;
}
const EMPTY_DESIGN_SYSTEMS: DesignSystemSummary[] = [];
/** Fallback for a caller with no sharing surface (no workspace, no grids). */
const NOTHING_SHARED: SharedProjectPredicate = () => false;
/** The chip a design-system project wears. Product name, not a translated
* string — shared by the card tag and the type filter so both read alike. */
const DESIGN_SYSTEM_TAG_LABEL = 'Design System';
type DictKey = Parameters<ReturnType<typeof useT>>[0];
type OwnerFilter = 'all' | 'mine' | 'others';
/** The type filter speaks the SAME vocabulary the cards stamp on themselves
* ({@link projectCardCategory}), so "原型 / 幻灯片 / 实时看板 / 媒体 /
* Design System" in the dropdown mean exactly the chips a user can read off
* the grid. It used to run a private taxonomy off `metadata.kind`
* (prototype/deck/media/other), which offered a 其他 bucket no card ever
* shows and no 实时看板 / Design System filter for chips every card does. */
type ProjectKindFilter = 'all' | ProjectCardCategory;
type ProjectSort = 'updatedDesc' | 'updatedAsc' | 'nameAsc';
const OWNER_FILTER_OPTIONS: Array<{ id: OwnerFilter; labelKey: DictKey }> = [
{ id: 'all', labelKey: 'recentProjects.ownerAll' },
{ id: 'mine', labelKey: 'recentProjects.ownerMine' },
{ id: 'others', labelKey: 'recentProjects.ownerOthers' },
];
type KindFilterOption =
| { id: ProjectKindFilter; labelKey: DictKey; label?: undefined }
| { id: ProjectKindFilter; label: string; labelKey?: undefined };
// One entry per chip the grid can render, reusing that chip's own i18n key so
// the filter label and the card label can never drift apart. `brand` is absent
// on purpose: `projectCardCategory` resolves every brand-kind project to
// 'design-system' first (see `isDesignSystemProject`), so a 'brand' option
// could only ever match nothing.
const KIND_FILTER_OPTIONS: KindFilterOption[] = [
{ id: 'all', labelKey: 'recentProjects.kindAll' },
{ id: 'prototype', labelKey: 'designs.tagPrototype' },
{ id: 'slide', labelKey: 'designs.tagSlide' },
{ id: 'live-artifact', labelKey: 'designs.tagLiveArtifact' },
{ id: 'web-clone', labelKey: 'designs.tagWebClone' },
{ id: 'media', labelKey: 'designs.tagMedia' },
{ id: 'design-system', label: DESIGN_SYSTEM_TAG_LABEL },
];
function kindFilterLabel(option: KindFilterOption, t: ReturnType<typeof useT>): string {
return option.labelKey === undefined ? option.label : t(option.labelKey);
}
const SORT_OPTIONS: Array<{ id: ProjectSort; labelKey: Parameters<ReturnType<typeof useT>>[0] }> = [
{ id: 'updatedDesc', labelKey: 'recentProjects.sortNewest' },
{ id: 'updatedAsc', labelKey: 'recentProjects.sortOldest' },
{ id: 'nameAsc', labelKey: 'recentProjects.sortName' },
];
const DECK_PREVIEW_WIDTH = 1280;
const DECK_PREVIEW_HEIGHT = 720;
// Deck covers are fetched once per artifact URL and shared by every card that
// points at it: the parsed srcDoc is cached, and concurrent mounts join the
// same in-flight request instead of re-fetching.
const deckCoverCache = new Map<string, string>();
const deckCoverInflight = new Map<string, Promise<string>>();
const DEFAULT_RECENT_PROJECT_LIMIT = 6;
const WIDE_RECENT_PROJECT_LIMIT = 7;
// Card covers are background decoration. Browsers commonly allow only six
// concurrent connections per origin, so an unbounded All Projects scan can
// occupy every slot and queue the project file list/preview the user just
// opened. Two cover probes keep the grid moving while reserving capacity for
// foreground reads.
const MAX_BACKGROUND_COVER_REQUESTS = 2;
// 7 * 180px cards + 6 * 12px gaps, matching recent-projects.css.
const WIDE_RECENT_PROJECT_MIN_ROW_WIDTH = 1332;
type BackgroundTask<T> = {
controller: AbortController;
run: () => Promise<T>;
resolve: (value: T | undefined) => void;
reject: (reason: unknown) => void;
started: boolean;
released: boolean;
settled: boolean;
};
class BackgroundTaskQueue {
private active = 0;
private readonly pending: BackgroundTask<unknown>[] = [];
private pauseDepth = 0;
constructor(private readonly concurrency: number) {}
schedule<T>(
controller: AbortController,
run: () => Promise<T>,
priority = false,
): Promise<T | undefined> {
return new Promise<T | undefined>((resolve, reject) => {
const task: BackgroundTask<T> = {
controller,
run,
resolve,
reject,
started: false,
released: false,
settled: false,
};
const abort = () => {
if (task.settled) return;
task.settled = true;
task.resolve(undefined);
this.release(task);
this.drain();
};
controller.signal.addEventListener('abort', abort, { once: true });
// Store listener cleanup on the promise path without expanding the
// queue's public contract. A settled task's one-shot abort listener is
// harmless, but removing it avoids retaining component closures.
task.run = async () => {
try {
return await run();
} finally {
controller.signal.removeEventListener('abort', abort);
}
};
if (priority) {
this.pending.unshift(task as BackgroundTask<unknown>);
} else {
this.pending.push(task as BackgroundTask<unknown>);
}
this.drain();
});
}
withoutDraining(run: () => void): void {
this.pauseDepth += 1;
try {
run();
} finally {
this.pauseDepth -= 1;
this.drain();
}
}
private release<T>(task: BackgroundTask<T>): void {
if (task.started && !task.released) {
task.released = true;
this.active -= 1;
return;
}
if (!task.started) {
const index = this.pending.indexOf(task as BackgroundTask<unknown>);
if (index >= 0) this.pending.splice(index, 1);
}
}
private drain(): void {
if (this.pauseDepth > 0) return;
while (this.active < this.concurrency && this.pending.length > 0) {
const task = this.pending.shift()!;
if (task.settled || task.controller.signal.aborted) continue;
task.started = true;
this.active += 1;
void task.run().then(
(value) => {
if (task.settled) return;
task.settled = true;
task.resolve(value);
this.release(task);
this.drain();
},
(error) => {
if (task.settled) return;
task.settled = true;
task.reject(error);
this.release(task);
this.drain();
},
);
}
}
}
export function RecentProjectsStrip({
projects,
designSystems = EMPTY_DESIGN_SYSTEMS,
heading,
description,
onOpen,
onViewAll,
onDelete,
onDuplicate,
onRename,
limit,
isSharedProject,
onProjectShared,
onProjectUnshared,
space = 'recent',
projectOwnerMemberIds,
openingProjectId = null,
collaborationEnabled,
canAssignInviteRoles,
canManageProjectCollection,
isActive = true,
}: Props) {
const t = useT();
const rowRef = useRef<HTMLDivElement | null>(null);
// Real creator resolution (replaces the demo's mock 李娜/张伟 roster): the
// member directory turns an ownerMemberId into a display name, and the
// workspace context tells us which member is "me" so the owner's own cards
// read "我创建" instead of their display name. Both hooks degrade to
// empty/null off-team, so every card safely falls back to "我创建".
const { resolve: resolveMember } = useTeamMembers();
const { context: workspaceContext } = useWorkspaceContext();
// Cover snapshots are keyed by workspace so a cached decision can never
// leak across workspaces (Batch A §4.2). Read through a ref so the async
// workspace-context resolution (null → id shortly after mount) does not
// change `requestProjectCover`'s identity and re-trigger a full cover pass.
const workspaceIdRef = useRef<string | null>(workspaceContext?.workspaceId ?? null);
workspaceIdRef.current = workspaceContext?.workspaceId ?? null;
const workspaceBilling = useWorkspaceBilling();
const selfMemberId = workspaceContext?.workspaceMemberId ?? null;
// `canShareProjects` alone is a ROLE permission ("could this member share IF
// a team existed"), not a "does a team exist" signal — a purely personal
// workspace's owner still gets `canShareProjects: true`. Without also
// requiring `workspaceContextHasTeamIdentity`, this stayed true for a
// personal-only workspace and the move-to-team menu item rendered a button
// the daemon can only ever 403 (recvqfZsR901YQ "无法共享方案了" /
// recvqgif6Xa7Wb "隐藏非 Team workspace 分享到团队的入口") — the exact class
// of bug `workspaceContextHasTeamIdentity`'s own doc comment warns about:
// "Deriving it twice is how a UI grows a button that can only ever fail."
const collaborationAvailable =
collaborationEnabled ??
(workspaceContextHasTeamIdentity(workspaceContext) &&
workspaceContext?.permissions.canShareProjects === true);
const canAccessInviteFlow = canAccessWorkspaceInviteFlow(workspaceContext);
// The invite dialog's seat-gate upgrade CTA: personal workspace → B's
// personal plan modal, team → checkout vs change-plan by subscription state.
// One shared decision point — see `workspaceUpgradeUrl` in EntryNavRail.tsx
// (recvpYEiH019cD).
const inviteUpgradeUrl = workspaceUpgradeUrl(workspaceContext, workspaceBilling);
const inviteTarget = resolveWorkspaceInviteTarget(workspaceContext);
const canManageCollection =
canManageProjectCollection ??
(workspaceContext?.permissions.canManageSharedResources === true ||
workspaceContext?.permissions.canShareProjects === true);
const [responsiveLimit, setResponsiveLimit] = useState(DEFAULT_RECENT_PROJECT_LIMIT);
const resolvedLimit = limit ?? responsiveLimit;
const hasRecentProjects = projects.length > 0;
const fullPageGrid = heading !== undefined || description !== undefined || space !== 'recent';
const showOwnerFilter = space !== 'drafts';
const [view, setView] = useState<'grid' | 'list'>('grid');
const [ownerFilter, setOwnerFilter] = useState<OwnerFilter>('all');
const [kindFilter, setKindFilter] = useState<ProjectKindFilter>('all');
const [sort, setSort] = useState<ProjectSort>('updatedDesc');
// recvqbipG9QDTt: this component mounts once per host view (Home, Drafts,
// All projects) and stays alive across EntryShell tab switches — Home's
// instance in particular is only ever hidden via `content-visibility`, not
// unmounted (see EntryShell's `inactiveViewProps`) — so a filter picked
// here keeps silently narrowing the grid on every later visit with no cue
// that anything is filtered. Surfacing `hasActiveFilter` drives the visible
// "clear filters" chip below instead of switching tabs quietly resetting
// it, per the reporter's own preferred fix.
const hasActiveFilter = ownerFilter !== 'all' || kindFilter !== 'all';
const [openHeaderMenu, setOpenHeaderMenu] = useState<'owner' | 'kind' | 'sort' | null>(null);
const [inviteOpen, setInviteOpen] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(() => new Set());
// Confirmation gates for the bulk bar. Batch move reuses the single-card
// 不再提示 opt-out; batch delete always confirms (it is irreversible and
// spans N projects), mirroring the projects grid's own batch delete.
const [bulkMoveAction, setBulkMoveAction] = useState<'to-team' | 'to-personal' | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
useEffect(() => {
if (limit !== undefined) return;
const update = () => {
const rowWidth = rowRef.current?.getBoundingClientRect().width;
if (rowWidth === undefined) {
setResponsiveLimit(DEFAULT_RECENT_PROJECT_LIMIT);
return;
}
setResponsiveLimit(
rowWidth >= WIDE_RECENT_PROJECT_MIN_ROW_WIDTH
? WIDE_RECENT_PROJECT_LIMIT
: DEFAULT_RECENT_PROJECT_LIMIT,
);
};
update();
const node = rowRef.current;
if (node && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}
if (typeof window === 'undefined') return;
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [hasRecentProjects, limit]);
const sortedProjects = useMemo(
() => [...projects].sort((a, b) => {
if (sort === 'updatedAsc') return a.updatedAt - b.updatedAt;
if (sort === 'nameAsc') return a.name.localeCompare(b.name);
return b.updatedAt - a.updatedAt;
}),
[projects, sort],
);
const [coverByProject, setCoverByProject] = useState<
Record<string, ProjectCoverOverride | null>
>({});
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<{ id: string; original: string } | null>(null);
const [renameInput, setRenameInput] = useState('');
const [confirmTarget, setConfirmTarget] = useState<Project | null>(null);
// recvqbh189zBY6: commitDelete used to await onDelete and drop the result on
// the floor either way — a 403/network failure closed the dialog exactly
// like a success, leaving the project right where it was with no signal
// that anything went wrong. Track failure so the dialog can stay open and
// say so instead of silently doing nothing.
const [deleteFailed, setDeleteFailed] = useState(false);
// Project → team-space sharing (the project card entry). The daemon gates on
// `canShareProjects` (403 off-team / no rights), so we only badge on success.
const [sharingId, setSharingId] = useState<string | null>(null);
const [unsharingId, setUnsharingId] = useState<string | null>(null);
const [shareErrorProjectId, setShareErrorProjectId] = useState<string | null>(null);
// 'owner-conflict' is the daemon's TEAM_PROJECT_OWNER_CONFLICT refusal: the
// team hub already registers this project under another member's ownership.
// That state is permanent until the registered owner unshares, so it gets
// its own message instead of the retryable 'share' hint.
const [shareErrorKind, setShareErrorKind] = useState<'share' | 'unshare' | 'owner-conflict'>('share');
// Whether a card is team-shared is decided upstream, not here — the grids'
// 全部项目 / 草稿 partition reads the very same predicate, so the badge and the
// card's grid can no longer disagree.
const isShared = isSharedProject ?? NOTHING_SHARED;
// The card's "{creator}创建" line. A project the team hub attributes to another
// member resolves through the directory to that member's display name; my own
// shares and every local (non-shared) project read "我创建". Falls back to a
// generic "团队成员" when a shared project's owner is not yet in the directory
// (off-team, or a member the daemon has not seen register), never an opaque id.
const resolveCreator = (projectId: string): { name: string; initial: string; ownedBySelf: boolean } => {
const ownerMemberId = projectOwnerMemberIds?.get(projectId) ?? null;
if (!ownerMemberId || ownerMemberId === selfMemberId) {
const name = t('recentProjects.selfCreator');
const initial = Array.from(name.trim())[0]?.toUpperCase() ?? 'M';
return { name, initial, ownedBySelf: true };
}
const name = resolveMember(ownerMemberId)?.displayName ?? t('recentProjects.teamMemberCreator');
const initial = (Array.from(name.trim())[0] ?? 'T').toUpperCase();
return { name, initial, ownedBySelf: false };
};
const visibleProjects = useMemo(
() => sortedProjects
.map((project) => ({ project, creator: resolveCreator(project.id) }))
.filter(({ project, creator }) => {
const ownerMatches =
!showOwnerFilter ||
ownerFilter === 'all' ||
(ownerFilter === 'mine' && creator.ownedBySelf) ||
(ownerFilter === 'others' && !creator.ownedBySelf);
const kindMatches = kindFilter === 'all' || projectCardCategory(project) === kindFilter;
return ownerMatches && kindMatches;
})
.slice(0, resolvedLimit),
[
kindFilter,
ownerFilter,
projectOwnerMemberIds,
resolvedLimit,
selfMemberId,
showOwnerFilter,
sortedProjects,
],
);
const menuContainerRef = useRef<HTMLDivElement | null>(null);
const renameTitleId = useId();
const confirmTitleId = useId();
const moveTitleId = useId();
const bulkMoveTitleId = useId();
const bulkDeleteTitleId = useId();
// #5517 move confirmation: moving a project in/out of the team space asks
// once, with a persisted 不再提示 opt-out (the demo keeps it per-session;
// the product remembers the choice).
const [moveTarget, setMoveTarget] = useState<{ project: Project; action: 'to-team' | 'to-personal' } | null>(null);
const [moveDontRemind, setMoveDontRemind] = useState<boolean>(() => {
try {
return window.localStorage.getItem(MOVE_CONFIRM_SKIP_KEY) === '1';
} catch {
return false;
}
});
function requestMove(project: Project, action: 'to-team' | 'to-personal') {
if (moveDontRemind) {
void (action === 'to-team' ? handleShareToTeam(project) : handleUnshareFromTeam(project));
return;
}
setMenuOpenId(null);
setMoveTarget({ project, action });
}
function commitMove() {
if (!moveTarget) return;
if (moveDontRemind) {
try {
window.localStorage.setItem(MOVE_CONFIRM_SKIP_KEY, '1');
} catch {
// best-effort persistence
}
}
const { project, action } = moveTarget;
setMoveTarget(null);
void (action === 'to-team' ? handleShareToTeam(project) : handleUnshareFromTeam(project));
}
const actionsAvailable = Boolean(onDelete || onDuplicate || onRename || collaborationAvailable);
// Bulk-action state for the 多选 bar. Every action below is the batch form of
// an action the per-card ⋯ menu already offers (move in/out of the team
// space, delete); nothing new is exposed here that a single card cannot do.
const selectedProjects = visibleProjects.filter(({ project }) => selectedProjectIds.has(project.id));
const selectedCount = selectedProjectIds.size;
// Same gate as the per-card menu: only your own projects can be moved or
// deleted, so a selection containing someone else's shared project disables
// the mutations instead of half-applying them.
const selectionHasForeignProject = selectedProjects.some(({ creator }) => !creator.ownedBySelf);
const bulkMutationDisabled = selectedCount === 0 || selectionHasForeignProject;
const bulkMutationTitle = selectionHasForeignProject
? t('recentProjects.ownOnlyMutation')
: selectedProjects.map(({ project }) => project.name).join('、') || undefined;
const canBulkMoveToTeam = collaborationAvailable && space !== 'team';
const canBulkMoveToPersonal = collaborationAvailable && space !== 'drafts';
useEffect(() => {
setSelectedProjectIds((current) => {
if (current.size === 0) return current;
const visibleIds = new Set(visibleProjects.map(({ project }) => project.id));
const next = new Set([...current].filter((id) => visibleIds.has(id)));
return next.size === current.size ? current : next;
});
}, [visibleProjects]);
useEffect(() => {
if (!menuOpenId) return;
function handlePointerDown(event: PointerEvent) {
const target = event.target;
if (target instanceof Node && menuContainerRef.current?.contains(target)) return;
setMenuOpenId(null);
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [menuOpenId]);
// Cover fetching must key off the *set of project ids*, not the
// `visibleProjects` array reference. That reference changes on every render
// (upstream props/derived lists are recreated, and a 2s poll re-renders the
// shell), and depending on it re-ran this effect — and re-fetched every
// project's files — on every render (observed ~23× per project in a trace).
const coverFetchKey = visibleProjects.map(({ project }) => project.id).join('|');
const visibleProjectsRef = useRef(new Map<string, Project>());
visibleProjectsRef.current = new Map(
visibleProjects.map(({ project }) => [project.id, project]),
);
const coverGenerationRef = useRef(new Map<string, number>());
const activeRef = useRef(isActive);
activeRef.current = isActive;
const coverQueueRef = useRef<BackgroundTaskQueue | null>(null);
if (!coverQueueRef.current) {
coverQueueRef.current = new BackgroundTaskQueue(MAX_BACKGROUND_COVER_REQUESTS);
}
const coverQueue = coverQueueRef.current;
const coverInFlightRef = useRef(
new Map<string, {
controller: AbortController;
generation: number;
promise: Promise<void>;
}>(),
);
// Resolves one project's cover decision. Returns:
// - a cover override when the project has a renderable cover,
// - `null` as the *authoritative* "this project has no cover" answer
// (safe to snapshot until the project version changes), and
// - `undefined` for transient outcomes (abort, network failure) that must
// not be cached or written into state.
const loadProjectCover = useCallback(async (
project: Project,
signal: AbortSignal,
): Promise<ProjectCoverOverride | null | undefined> => {
const designSystemProject = isDesignSystemProject(project);
if (project.metadata?.entryFile && !designSystemProject) return null;
let files: Awaited<ReturnType<typeof fetchProjectFiles>>;
try {
files = await fetchProjectFiles(project.id, { signal });
} catch {
return undefined;
}
if (signal.aborted) return undefined;
if (designSystemProject) {
return (await findDesignSystemCover(project.id, files, signal)) ?? null;
}
const cover = selectProjectFileCover(files);
if (cover?.kind !== 'html') return cover;
const src = projectCoverUrl(project.id, cover.name, cover.mtime);
const diagnostic = `${project.id}:${cover.name}`;
if (project.metadata?.kind === 'deck') {
try {
await loadDeckCover(src, signal);
return signal.aborted ? undefined : cover;
} catch (err) {
if (signal.aborted || (err instanceof DOMException && err.name === 'AbortError')) return undefined;
console.warn('[project-cover] failed to load HTML cover:', diagnostic, err);
return undefined;
}
}
try {
const response = await fetch(src, {
method: 'HEAD',
cache: 'no-store',
signal,
});
if (signal.aborted) return undefined;
if (response.ok || response.status === 304) return cover;
console.warn(
`[project-cover] HTML cover unavailable (${response.status} ${response.statusText}):`,
diagnostic,
);
// The server answered: the cover file is not readable. That decision is
// cacheable; the card renders its glyph until the project changes.
return null;
} catch (err) {
if (signal.aborted || (err instanceof DOMException && err.name === 'AbortError')) return undefined;
console.warn('[project-cover] failed to verify HTML cover:', diagnostic, err);
return undefined;
}
}, []);
const requestProjectCover = useCallback((
project: Project,
options: { force?: boolean } = {},
): Promise<void> => {
if (!activeRef.current) return Promise.resolve();
const snapshotKey = projectCoverSnapshotKey(
workspaceIdRef.current,
project.id,
project.updatedAt,
);
if (!options.force) {
// Serve the last successful decision for this exact workspace/project/
// version instead of re-running the files scan + probe on every
// remount. Stale versions miss the key; content-ready events
// invalidate explicitly (Batch A §4.2).
const snapshot = getProjectCoverSnapshot(snapshotKey);
if (snapshot !== undefined) {
if (visibleProjectsRef.current.has(project.id)) {
setCoverByProject((current) =>
current[project.id] === snapshot.cover
? current
: { ...current, [project.id]: snapshot.cover },
);
}
return Promise.resolve();
}
}
const existing = coverInFlightRef.current.get(project.id);
if (existing && !options.force) return existing.promise;
const generation = (coverGenerationRef.current.get(project.id) ?? 0) + 1;
coverGenerationRef.current.set(project.id, generation);
const controller = new AbortController();
const promise = coverQueue.schedule(
controller,
() => loadProjectCover(project, controller.signal),
options.force,
)
.then((cover) => {
if (controller.signal.aborted) return;
if (cover === undefined) return;
if (coverGenerationRef.current.get(project.id) !== generation) return;
setProjectCoverSnapshot(snapshotKey, cover);
if (!visibleProjectsRef.current.has(project.id)) return;
setCoverByProject((current) => ({ ...current, [project.id]: cover }));
})
.finally(() => {
// Generation values can be reused after a StrictMode synthetic cleanup
// clears the maps. Only the exact request that installed this entry may
// remove it; otherwise late settlement from replay A can erase replay
// B, leaving the real unmount with no controller to abort.
if (coverInFlightRef.current.get(project.id)?.controller === controller) {
coverInFlightRef.current.delete(project.id);
}
});
coverInFlightRef.current.set(project.id, { controller, generation, promise });
// Install the replacement first so a force-refresh enters the front of the
// queue before aborting its stale predecessor releases a slot.
existing?.controller.abort();
return promise;
}, [coverQueue, loadProjectCover]);
const abortBackgroundCoverRequests = useCallback(() => {
coverQueue.withoutDraining(() => {
for (const request of coverInFlightRef.current.values()) {
request.controller.abort();
}
});
coverInFlightRef.current.clear();
}, [coverQueue]);
// Cards report themselves through a per-card viewport sentinel; only cards
// that have actually been near the viewport ever start cover work
// (Batch A §4.2). The set is per-mount on purpose: a fresh strip instance
// re-discovers visibility, while resolved decisions come from the snapshot
// cache.
const coverSentinelSeenRef = useRef(new Set<string>());
const handleCoverCardVisible = useCallback((projectId: string) => {
if (coverSentinelSeenRef.current.has(projectId)) return;
coverSentinelSeenRef.current.add(projectId);
const project = visibleProjectsRef.current.get(projectId);
if (!project) return;
void requestProjectCover(project);
}, [requestProjectCover]);
const resumeBackgroundCoverRequests = useCallback(() => {
if (!activeRef.current) return;
resumeThumbnailLoads();
for (const project of visibleProjectsRef.current.values()) {
if (!coverSentinelSeenRef.current.has(project.id)) continue;
void requestProjectCover(project);
}
}, [requestProjectCover]);
useEffect(() => {
return () => {
// Cover probes are background-only. Do not let them survive navigation
// away from Home and occupy the connections needed by the reopened
// project's file list and preview source.
abortBackgroundCoverRequests();
coverGenerationRef.current.clear();
};
}, [abortBackgroundCoverRequests]);
const refreshProjectCover = useCallback((projectId: string) => {
// A content-ready event is authoritative: the stored cover decision (any
// version) is void even if the card is currently offscreen or unlisted.
invalidateProjectCoverSnapshots(projectId);
const project = visibleProjectsRef.current.get(projectId);
if (!project) return;
if (!coverSentinelSeenRef.current.has(projectId)) return;
// Supersedes an older initial scan that may still be resolving against
// the pre-pull filesystem.
void requestProjectCover(project, { force: true });
}, [requestProjectCover]);
useWorkspaceInvalidation(
{
'team-project-content-ready': ({ projectId, workspaceId }) => {
if (!activeRef.current) return;
if (workspaceContext?.workspaceId !== workspaceId) return;
void refreshProjectCover(projectId);
},
},
{
// Thin SSE events are not replayed. On reconnect/focus, retry only cards
// whose initial scan found no local cover, closing a missed-ready gap
// without re-fetching every already-resolved card in the grid.
onActive: () => {
if (!activeRef.current) return;
for (const { project } of visibleProjects) {
if (!coverSentinelSeenRef.current.has(project.id)) continue;
if (coverByProject[project.id] == null) {
void requestProjectCover(project);
}
}
},
},
);
useEffect(() => {
const visibleIds = new Set(visibleProjects.map(({ project }) => project.id));
if (!isActive) {
abortBackgroundCoverRequests();
return;
}
const staleRequests = [...coverInFlightRef.current.entries()]
.filter(([projectId]) => !visibleIds.has(projectId));
coverQueue.withoutDraining(() => {
for (const [projectId, request] of staleRequests) {
request.controller.abort();
coverInFlightRef.current.delete(projectId);
coverGenerationRef.current.delete(projectId);
}
});
if (visibleProjects.length === 0) {
setCoverByProject({});
return;
}
setCoverByProject((current) => {
const entries = Object.entries(current).filter(([projectId]) => visibleIds.has(projectId));
return entries.length === Object.keys(current).length
? current
: Object.fromEntries(entries);
});
for (const { project } of visibleProjects) {
if (!coverSentinelSeenRef.current.has(project.id)) continue;
void requestProjectCover(project);
}
// Intentionally keyed on the id set (coverFetchKey), not visibleProjects,
// so re-renders that don't change which projects are shown don't re-fetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [abortBackgroundCoverRequests, coverFetchKey, coverQueue, isActive, requestProjectCover]);
// First-run home shouldn't reserve space for an empty "Recent
// projects" rail — the dashed empty box just adds visual noise
// above the plugin gallery. We also skip rendering during the
// load window so the section doesn't pop in and then collapse;
// the prompt hero is enough chrome on its own.
// Home rail only: an empty rail is dropped entirely (dashed empty chrome is
// noise over the plugin gallery). The FULL-PAGE grids (drafts/all-projects)
// must keep their header + filter toolbar even when the current owner/type
// filter matches nothing — collapsing them stranded the user with no way to
// change the filter back.
if (visibleProjects.length === 0 && !fullPageGrid) {
return null;
}
function startRename(project: Project) {
const creator = resolveCreator(project.id);
if (!creator.ownedBySelf) return;
setMenuOpenId(null);
setRenameTarget({ id: project.id, original: project.name });
setRenameInput(project.name);
}
function cancelRename() {
setRenameTarget(null);
setRenameInput('');
}
function commitRename() {
if (!renameTarget || !onRename) return;
const trimmed = renameInput.trim();
if (trimmed && trimmed !== renameTarget.original) {
onRename(renameTarget.id, trimmed);
}
cancelRename();
}
function requestDelete(project: Project) {
const creator = resolveCreator(project.id);
if (!creator.ownedBySelf) return;
setMenuOpenId(null);
setDeleteFailed(false);
setConfirmTarget(project);
}
// Promote/demote a project through the same workspace move endpoint used by
// the full project grid so cards and in-file sharing cannot drift.
async function handleShareToTeam(project: Project) {
setShareErrorProjectId(null);
setMenuOpenId(project.id);
setSharingId(project.id);
try {
await moveWorkspaceProject({
projectId: project.id,
visibility: 'team',
workspaceContext,
});
onProjectShared?.(project.id);
notifyTeamProjectsChanged();
setMenuOpenId(null);
} catch (err) {
console.warn('[RecentProjectsStrip] share project to team failed:', err);
setShareErrorProjectId(project.id);
setShareErrorKind(
workspaceProjectMoveErrorCode(err) === 'TEAM_PROJECT_OWNER_CONFLICT'
? 'owner-conflict'
: 'share',
);
setMenuOpenId(project.id);
} finally {
setSharingId(null);
}
}
async function handleUnshareFromTeam(project: Project) {
setShareErrorProjectId(null);
setMenuOpenId(project.id);
setUnsharingId(project.id);
try {
await moveWorkspaceProject({
projectId: project.id,
visibility: 'personal',
workspaceContext,
});
onProjectUnshared?.(project.id);
notifyTeamProjectsChanged();
setMenuOpenId(null);
} catch (err) {
console.warn('[RecentProjectsStrip] unshare project from team failed:', err);
setShareErrorProjectId(project.id);
setShareErrorKind('unshare');
setMenuOpenId(project.id);
} finally {
setUnsharingId(null);
}
}
function requestDuplicate(project: Project) {
if (!onDuplicate) return;
// Same ownership gate the menu item's `disabled` already enforces (see
// recvqaRqM0dv2x above) — kept here too so the handler itself can never
// fire the doomed-to-403 request, matching startRename/requestDelete's
// own defense-in-depth check.
const creator = resolveCreator(project.id);
if (!creator.ownedBySelf) return;
setMenuOpenId(null);
void Promise.resolve(onDuplicate(project.id)).catch((err) => {
console.warn('[RecentProjectsStrip] duplicate project failed:', err);
});
}
async function commitDelete() {
if (!confirmTarget || !onDelete) return;
const target = confirmTarget;
setDeleteFailed(false);
try {
const result = await onDelete(target.id);
// A falsy result (false, or void from a caller that never resolves the
// promise either way) means the daemon refused or the request failed —
// keep the dialog open with a visible reason instead of closing it as
// if the project were gone (recvqbh189zBY6).
if (result === false) {
setDeleteFailed(true);
return;
}
setConfirmTarget(null);
} catch (err) {
console.warn('[RecentProjectsStrip] delete project failed:', err);
setDeleteFailed(true);
}
}
function toggleSelection(projectId: string) {
setSelectedProjectIds((current) => {
const next = new Set(current);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
return next;
});
}
function exitSelectionMode() {
setSelectionMode(false);
setSelectedProjectIds(new Set());
}
/** Shared by the single-card and the bulk move confirmations so both spell
* out the same consequence of crossing the team-space boundary. */
function moveDescription(action: 'to-team' | 'to-personal') {
return action === 'to-team' ? (
<>
{t('recentProjects.moveToTeamDescPre')}
<strong>{t('recentProjects.moveToTeamDescStrong')}</strong>
{t('recentProjects.moveToTeamDescPost')}
</>
) : (
<>
{t('recentProjects.moveToPersonalDescPre')}
<strong>{t('recentProjects.moveToPersonalDescStrong')}</strong>
{t('recentProjects.moveToPersonalDescPost')}
</>
);
}
function requestBulkMove(action: 'to-team' | 'to-personal') {
if (bulkMutationDisabled) return;
if (moveDontRemind) {
void commitBulkMove(action);
return;
}
setBulkMoveAction(action);
}