返回 DeepSeek-Reasonix
projectTreeTopic.ts
根目录 / desktop / frontend / src / lib / projectTreeTopic.ts
1 import { asArray } from "./array";
2 import { getLocale, type DictKey, type Translator } from "./i18n";
3 import type { ProjectNode, ProjectTopicStatus } from "./types";
4 import { projectSessionIdentity, projectSessionExcluded, projectSessionKeys, sameProjectSession } from "./projectSessionIdentity";
5
6 export type ProjectTreeVariant = "workbench" | "creation";
7 export type WorkbenchSortMode = "created" | "updated";
8
9 // Shared by workbench and creation; key string kept for existing saved choices
10 // and for downgrade compatibility.
11 export const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
12 export const WORKBENCH_SORT_CREATED_DEFAULT_MIGRATION_KEY = "projectTree:workbenchSort:createdDefault:v1";
13
14 export function loadWorkbenchSortMode(): WorkbenchSortMode {
15 try {
16 if (localStorage.getItem(WORKBENCH_SORT_CREATED_DEFAULT_MIGRATION_KEY) !== "1") {
17 // This release intentionally resets every existing choice once. Keep the
18 // original preference key so older builds can still read the new value.
19 localStorage.setItem(WORKBENCH_SORT_KEY, "created");
20 localStorage.setItem(WORKBENCH_SORT_CREATED_DEFAULT_MIGRATION_KEY, "1");
21 return "created";
22 }
23 const value = localStorage.getItem(WORKBENCH_SORT_KEY);
24 if (value === "created" || value === "updated") return value;
25 } catch {
26 /* localStorage unavailable */
27 }
28 return "created";
29 }
30
31 export function isRuntimeSessionNode(node: ProjectNode): boolean {
32 return node.kind === "session" || node.kind === "global_session";
33 }
34
35 export function isTopicNode(node: ProjectNode): boolean {
36 return node.kind === "topic" || node.kind === "global_topic";
37 }
38
39 export function projectTreeRevisionIsFresh(currentRevision: number, incomingRevision: number): boolean {
40 return incomingRevision >= currentRevision;
41 }
42
43 export function projectTreeTopicPageIsFresh(
44 revisions: Readonly<Record<string, number>>,
45 projectKey: string,
46 incomingRevision: number,
47 ): boolean {
48 return projectTreeRevisionIsFresh(revisions[projectKey] ?? 0, incomingRevision);
49 }
50
51 // Project shells come from desktop-projects.json and are valid even when the
52 // disposable catalog still reports revision 0. Catalog revision only gates
53 // topic pages and non-empty tree refreshes after the first shell is painted.
54 export function projectTreeShouldApplyShellSnapshot(options: {
55 currentRevision: number;
56 incomingRevision: number;
57 treeEmpty: boolean;
58 }): boolean {
59 if (options.treeEmpty) return true;
60 return projectTreeRevisionIsFresh(options.currentRevision, options.incomingRevision);
61 }
62
63 export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[], append: boolean): ProjectNode[] {
64 // Owner aliases retire the source projection even when its old page arrives
65 // after adoption. Canonical identity always wins; its durable row metadata is retained.
66 incoming = incoming.map(row => !row.session ? current.find(old => old.session && sameProjectSession(old,row)) ?? row : row);
67 if (!append) {
68 // Project snapshots carry every pinned topic shell, while a lazy first
69 // page is bounded. Keep off-page pins so expanding a busy project cannot
70 // make its pinned section incomplete again.
71 const incomingKeys = new Set(incoming.flatMap(projectSessionKeys));
72 const offPagePins = current.filter((node) => Boolean(node.pinned) && !projectSessionKeys(node).some(key => incomingKeys.has(key)));
73 return [...incoming, ...offPagePins];
74 }
75 const next = [...current];
76 const positions = new Map(next.flatMap((node, index) => projectSessionKeys(node).map(key => [key,index] as const)));
77 for (const node of incoming) {
78 const index = projectSessionKeys(node).map(key => positions.get(key)).find(index => index !== undefined);
79 if (index === undefined) {
80 positions.set(projectSessionIdentity(node), next.length);
81 next.push(node);
82 } else {
83 next[index] = node;
84 }
85 }
86 const seen = new Set<string>();
87 return next.filter(node => {
88 const keys = projectSessionKeys(node);
89 if (keys.some(key => seen.has(key))) return false;
90 keys.forEach(key => seen.add(key)); return true;
91 });
92 }
93
94 // A directory scan commits catalog rows in batches, but an incomplete page is
95 // not authoritative for replacement, deletion, timestamps, or order. Keep the
96 // last complete resident rows byte-for-byte and append only newly discovered
97 // keys until a complete page can replace the canonical first page.
98 export function mergeIncompleteProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[]): ProjectNode[] {
99 const residents = new Map(current.flatMap(node => projectSessionKeys(node).map(key => [key, node] as const)));
100 const discovered = incoming.filter(node => {
101 const resident = projectSessionKeys(node).map(key => residents.get(key)).find(Boolean);
102 return !resident || Boolean(node.session && !resident.session);
103 });
104 return discovered.length === 0 ? current : mergeProjectTopicPage(current, discovered, true);
105 }
106
107 // Topic page loads rewrite children, so a signature keyed only on the project
108 // shells lets the debounced reload effect observe arrivals without re-arming
109 // itself on its own writes.
110 export function projectTreeShellSignature(tree: ProjectNode[]): string {
111 return tree.map((node) => node.key).join("\u001f");
112 }
113
114 // After archive, drop that topic immediately so a shell-only refresh cannot
115 // resurrect it from the previously loaded children.
116 export function projectTreeWithoutTopic(tree: ProjectNode[], topicId: string): ProjectNode[] {
117 const id = topicId.trim();
118 if (!id) return tree;
119 return projectTreeWithoutTopics(tree, new Set([id]));
120 }
121
122 // Post-commit archive IDs are a client-side tombstone overlay. Apply it to
123 // every incoming page as well as the resident tree so a pre-commit request
124 // cannot paint a topic back before the canonical reload acquires its sequence.
125 export function projectTreeWithoutTopics(tree: ProjectNode[], topicIds: ReadonlySet<string>): ProjectNode[] {
126 if (topicIds.size === 0) return tree;
127 let changed = false;
128 const next: ProjectNode[] = [];
129 for (const node of tree) {
130 if ((isTopicNode(node) || isRuntimeSessionNode(node)) &&
131 projectSessionExcluded(node, topicIds)) {
132 changed = true;
133 continue;
134 }
135 const children = asArray(node.children);
136 const filteredChildren = projectTreeWithoutTopics(children, topicIds);
137 if (filteredChildren !== children) {
138 changed = true;
139 next.push({ ...node, children: filteredChildren });
140 } else {
141 next.push(node);
142 }
143 }
144 return changed ? next : tree;
145 }
146
147 export function projectTreeWithoutSession(tree: ProjectNode[], target: ProjectNode): ProjectNode[] {
148 return projectTreeWithoutTopics(tree, new Set([projectSessionIdentity(target)]));
149 }
150
151 // After a successful rename, paint the new label immediately instead of
152 // waiting for the catalog event round-trip.
153 export function projectTreeWithTopicTitle(tree: ProjectNode[], topicId: string, title: string): ProjectNode[] {
154 const id = topicId.trim();
155 if (!id) return tree;
156 let changed = false;
157 const next: ProjectNode[] = [];
158 for (const node of tree) {
159 if (node.topicId === id && (isTopicNode(node) || isRuntimeSessionNode(node))) {
160 if (node.label !== title) {
161 changed = true;
162 next.push({ ...node, label: title });
163 } else {
164 next.push(node);
165 }
166 continue;
167 }
168 const children = asArray(node.children);
169 const renamedChildren = projectTreeWithTopicTitle(children, id, title);
170 if (renamedChildren !== children) {
171 changed = true;
172 next.push({ ...node, children: renamedChildren });
173 } else {
174 next.push(node);
175 }
176 }
177 return changed ? next : tree;
178 }
179
180 export function projectTreeWithSessionTitle(tree: ProjectNode[], target: ProjectNode, title: string): ProjectNode[] {
181 const identity = projectSessionIdentity(target);
182 return tree.map((node) => {
183 if ((isTopicNode(node) || isRuntimeSessionNode(node)) && projectSessionIdentity(node) === identity) {
184 return node.label === title ? node : { ...node, label: title };
185 }
186 if (!node.children?.length) return node;
187 const children = projectTreeWithSessionTitle(node.children, target, title);
188 return children.every((child, index) => child === node.children?.[index]) ? node : { ...node, children };
189 });
190 }
191
192 export function projectTreeFolderKeyForTopic(tree: ProjectNode[], topicId: string): string {
193 const id = topicId.trim();
194 if (!id) return "";
195 for (const node of tree) {
196 if (node.kind !== "project" && node.kind !== "global_folder") continue;
197 if (asArray(node.children).some((child) => child.topicId === id)) return node.key;
198 }
199 return "";
200 }
201
202 export function projectTreeFolderKeyForSession(tree: ProjectNode[], sessionPath: string): string {
203 const path = sessionPath.trim();
204 if (!path) return "";
205 const containsSession = (nodes: ProjectNode[]): boolean => nodes.some((node) =>
206 ((isRuntimeSessionNode(node) || isTopicNode(node)) && node.sessionPath?.trim() === path)
207 || containsSession(asArray(node.children)),
208 );
209 for (const node of tree) {
210 if (node.kind !== "project" && node.kind !== "global_folder") continue;
211 if (containsSession(asArray(node.children))) return node.key;
212 }
213 return "";
214 }
215
216 export function invalidateProjectTreeTopicLoads(sequences: Record<string, number>, keys: Iterable<string>): void {
217 for (const key of keys) {
218 let matched = false;
219 const prefix = `${key}\u001f`;
220 for (const sequenceKey of Object.keys(sequences)) {
221 if (sequenceKey !== key && !sequenceKey.startsWith(prefix)) continue;
222 sequences[sequenceKey] = (sequences[sequenceKey] ?? 0) + 1;
223 matched = true;
224 }
225 if (!matched) sequences[key] = (sequences[key] ?? 0) + 1;
226 }
227 }
228
229 export function projectTreeShellChildren(
230 previous: ProjectNode[] | undefined,
231 pinnedShells: ProjectNode[] | undefined = [],
232 ): ProjectNode[] {
233 const shells = asArray(pinnedShells).filter((node) => isTopicNode(node) && Boolean(node.pinned));
234 if (!previous || previous.length === 0) return shells;
235
236 const shellByKey = new Map(shells.map((node) => [node.key, node]));
237 const next = asArray(previous).map((node) => {
238 if (!isTopicNode(node)) return node;
239 const shell = shellByKey.get(node.key);
240 if (!shell) return node.pinned ? { ...node, pinned: false } : node;
241 shellByKey.delete(node.key);
242 return { ...node, ...shell, children: node.children ?? shell.children };
243 });
244 return [...next, ...shellByKey.values()];
245 }
246
247 export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean {
248 if (roots.length === 0) return true;
249 const root = project.kind === "global_folder" ? "" : project.root ?? "";
250 return roots.includes(root);
251 }
252
253 export type ProjectTreeTopicOpenRequest = {
254 scope: "global" | "project";
255 workspaceRoot: string;
256 topicId: string;
257 sessionPath?: string;
258 };
259
260 export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopicOpenRequest | null {
261 if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return null;
262 const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project";
263 return {
264 scope,
265 workspaceRoot: scope === "global" ? "" : node.root ?? "",
266 topicId: node.topicId ?? "",
267 sessionPath: node.session ? `session-id:${node.session.sessionId}` : node.source
268 ? `session-source:${encodeURIComponent(JSON.stringify({ ...node.source, title: node.label }))}` : node.sessionPath,
269 };
270 }
271
272 export type ProjectTreeTopicClickTarget = {
273 rowKey: string;
274 canRename: boolean;
275 };
276
277 export type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & {
278 timer: ReturnType<typeof setTimeout>;
279 };
280
281 export function projectTreeShouldSuppressOpenForRename(
282 pending: ProjectTreeTopicClickTarget | null,
283 next: ProjectTreeTopicClickTarget,
284 ): boolean {
285 return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename);
286 }
287
288 export type ProjectTreeFolderDisclosure = {
289 canExpand: boolean;
290 isOpen: boolean;
291 ariaExpanded?: boolean;
292 iconStackClassName: string;
293 };
294
295 // allowEmptyExpand lets a project shell open before its first topic page has
296 // arrived: without it an empty folder is inert, so expanding it could never
297 // start the load that fills it.
298 export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean, allowEmptyExpand = false): ProjectTreeFolderDisclosure {
299 const canExpand = hasChildren || allowEmptyExpand;
300 const isOpen = canExpand && isExpanded;
301 return {
302 canExpand,
303 isOpen,
304 ariaExpanded: canExpand ? isExpanded : undefined,
305 iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`,
306 };
307 }
308
309 function topicMatchesActiveIdentity(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string): boolean {
310 if (!node.topicId || !activeTopicId) return false;
311 const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project";
312 if (scope === "global") return activeScope === "global" && activeTopicId === node.topicId;
313 return activeScope === "project" && activeTopicId === node.topicId && activeWorkspaceRoot === node.root;
314 }
315
316 type ActiveRemoteSessionIdentity = {
317 hostId: string;
318 workspace: string;
319 sessionId?: string;
320 };
321
322 function remoteTopicMatchesActiveSession(node: ProjectNode, activeRemote?: ActiveRemoteSessionIdentity): boolean {
323 const remote = node.remoteSession;
324 const active = activeRemote;
325 if (!remote || !active || remote.hostId !== active.hostId || remote.workspace !== active.workspace) return false;
326 const sessionID = active.sessionId?.trim();
327 if (!sessionID) return false;
328 return remote.sessionId?.trim() === sessionID || (!remote.sessionId?.trim() && remote.name.trim() === sessionID);
329 }
330
331 export function topicIsActive(
332 node: ProjectNode,
333 activeScope?: string,
334 activeWorkspaceRoot?: string,
335 activeTopicId?: string,
336 activeSessionPath?: string,
337 activeRemote?: ActiveRemoteSessionIdentity,
338 ): boolean {
339 if (node.source?.headId) return projectTreeTopicOpenRequest(node)?.sessionPath === activeSessionPath;
340 if (node.session?.sessionId) {
341 return Boolean(activeSessionPath && (activeSessionPath === `session-id:${node.session.sessionId}` || activeSessionPath === node.sessionPath
342 || projectSessionKeys(node).includes(`path\u0000${activeSessionPath}`)));
343 }
344 if (isRuntimeSessionNode(node)) {
345 return Boolean(node.sessionPath && activeSessionPath && activeSessionPath === node.sessionPath);
346 }
347 if (!isTopicNode(node)) return false;
348 if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false;
349 // Remote filesystem paths are not globally unique: two hosts can expose the
350 // same absolute session path. Their synthesized rows already carry a
351 // host-qualified topicId, so never let the generic path fallback mark a row
352 // from another host active.
353 if (node.remoteSession) {
354 if (remoteTopicMatchesActiveSession(node, activeRemote)) return true;
355 return topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId);
356 }
357 if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath);
358 if (topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId)) return true;
359 return Boolean(node.sessionPath && activeSessionPath && activeSessionPath === node.sessionPath);
360 }
361
362 export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
363 const parts: string[] = [];
364 const turns = node.turns ?? 0;
365 if (node.turnsState === "unknown") parts.push(t("history.indexing"));
366 else if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
367 const activityAt = node.lastActivityAt || node.createdAt || 0;
368 if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
369 if (parts.length === 0) parts.push(t("projectTree.previously"));
370 return parts.join(" · ");
371 }
372
373 // Activity labels older than a week are already the calendar date (always the
374 // meta line's last part), so callers pairing the two keep a single copy.
375 export function projectTreeDedupedExactTime(metaLine: string, exactTime: string): string {
376 return exactTime && metaLine.endsWith(exactTime) ? "" : exactTime;
377 }
378
379 export function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
380 return topicActivityAt(node) ? "" : t("projectTree.previously");
381 }
382
383 const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
384 thinking: "projectTree.status.thinking",
385 finishing: "runtime.finishing",
386 unknown: "runtime.unknown",
387 cancelling: "status.jobStopping",
388 streaming: "projectTree.status.streaming",
389 waiting_confirmation: "projectTree.status.waitingConfirmation",
390 background_job: "projectTree.status.backgroundJob",
391 paused: "projectTree.status.paused",
392 awaiting_delivery: "projectTree.status.awaitingDelivery",
393 error: "projectTree.status.error",
394 diverged_recovery: "projectTree.status.divergedRecovery",
395 };
396
397 export function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
398 if (status === "finishing" || status === "cancelling" || status === "unknown") return status;
399 if (!status) return "";
400 if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "awaiting_delivery" || status === "error" || status === "diverged_recovery") {
401 return status;
402 }
403 return "";
404 }
405
406 export function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
407 // Ordinary list never surfaces recovery-branch status. Active runtime states
408 // only: thinking/streaming/waiting/etc. History owns other saved versions.
409 const live = node.running ? "streaming" : "";
410 const stored = normalizeTopicStatus(node.status);
411 if (stored && stored !== "diverged_recovery") return stored;
412 return live;
413 }
414
415 export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
416 if (node.status === "finishing" || node.status === "cancelling" || node.status === "unknown") return true;
417 if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true;
418 const status = normalizeTopicStatus(node.status);
419 if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true;
420 if (status === "paused" || status === "awaiting_delivery" || status === "error" || status === "diverged_recovery") return false;
421 return Boolean(node.running);
422 }
423
424 export function topicStatusLabel(node: ProjectNode, t: Translator): string {
425 const status = topicStatus(node);
426 return status ? t(topicStatusLabels[status]) : "";
427 }
428
429 export function topicActivityAt(node: ProjectNode): number {
430 return node.lastActivityAt || node.createdAt || 0;
431 }
432
433 export function topicReadRevision(node: ProjectNode): number {
434 if (node.session) return node.resultSequence ?? 0;
435 return topicActivityAt(node);
436 }
437
438 export function projectTreeReadActivityKey(node: ProjectNode): string | null {
439 if (node.session?.sessionId) return projectSessionIdentity(node);
440 if (node.sessionPath || node.source || node.remoteSession) return projectSessionIdentity(node);
441 const request = projectTreeTopicOpenRequest(node);
442 if (!request?.topicId) return null;
443 return [request.scope, request.workspaceRoot, request.topicId].join("\u001f");
444 }
445
446 export type ProjectTreeReadActivity = Record<string, number>;
447
448 export function projectTreeMigrateReadActivity(current: ProjectTreeReadActivity, storedVersion: number): ProjectTreeReadActivity {
449 if (storedVersion >= 2) return current;
450 let next = current;
451 for (const [key, revision] of Object.entries(current)) {
452 if (!key.startsWith("session\u001f") || revision !== 0) continue;
453 if (next === current) next = { ...current };
454 delete next[key];
455 }
456 return next;
457 }
458
459 export function projectTreeSeedReadActivity(nodes: readonly ProjectNode[], current: ProjectTreeReadActivity): ProjectTreeReadActivity {
460 let next = current;
461 const visit = (items: readonly ProjectNode[]) => {
462 for (const node of items) {
463 const key = projectTreeReadActivityKey(node);
464 // A stale catalog cache is exposed as a canonical session with pending
465 // metadata and resultSequence 0 while its durable log is rebuilt. Do not
466 // persist that placeholder as the read baseline: once the real sequence
467 // arrives it would make every historical result look newly unread.
468 if (node.session && node.turnsState === "ready" && key
469 && next[key] === undefined) {
470 if (next === current) next = { ...current };
471 next[key] = topicReadRevision(node);
472 }
473 visit(node.children ?? []);
474 }
475 };
476 visit(nodes);
477 return next;
478 }
479
480 export function projectTreeTopicHasUnreadActivity(
481 node: ProjectNode,
482 readActivity: ProjectTreeReadActivity,
483 activeScope?: string,
484 activeWorkspaceRoot?: string,
485 activeTopicId?: string,
486 activeSessionPath?: string,
487 baselineAt = 0,
488 ): boolean {
489 if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
490 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
491 if (topicStatus(node) !== "") return false;
492 const key = projectTreeReadActivityKey(node);
493 const revision = topicReadRevision(node);
494 if (!key || revision <= 0) return false;
495 if (node.session) return readActivity[key] !== undefined && readActivity[key] < revision;
496 return Math.max(readActivity[key] ?? 0, baselineAt) < revision;
497 }
498
499 export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean {
500 return !isSessionNode && variant !== "creation" && !unread;
501 }
502
503 // Pinning reorders the trees shared with creation mode, so the creation
504 // context menu keeps its original rename/trash-only entries.
505 export function projectTreeTopicMenuOffersPin(variant: ProjectTreeVariant): boolean {
506 return variant !== "creation";
507 }
508
509 export function topicActivityLabel(ms: number, t: Translator, compact = false): string {
510 if (ms <= 0) return "";
511 const delta = Date.now() - ms;
512 const locale = getLocale();
513 const minute = 60_000;
514 const hour = 60 * minute;
515 const day = 24 * hour;
516 const month = 30 * day;
517 const year = 365 * day;
518 if (delta < minute) return t("projectTree.justNow");
519 if (!compact) {
520 const rtfLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
521 const rtf = new Intl.RelativeTimeFormat(rtfLocale, { numeric: "auto" });
522 if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
523 if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
524 if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
525 return topicActivityDateLabel(ms);
526 }
527 if (delta < hour) {
528 const value = Math.max(1, Math.round(delta / minute));
529 return locale === "zh" || locale === "zh-TW" ? `${value} 分钟` : `${value}m`;
530 }
531 if (delta < day) {
532 const value = Math.round(delta / hour);
533 return locale === "zh" || locale === "zh-TW" ? `${value} 小时` : `${value}h`;
534 }
535 if (delta < 7 * day) {
536 const value = Math.round(delta / day);
537 return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
538 }
539 if (delta < month) {
540 const value = Math.round(delta / day);
541 return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
542 }
543 if (delta < year) {
544 const value = Math.max(1, Math.round(delta / month));
545 return locale === "zh" || locale === "zh-TW" ? `${value} 个月` : `${value}mo`;
546 }
547 const value = Math.max(1, Math.round(delta / year));
548 return locale === "zh" || locale === "zh-TW" ? `${value} 年` : `${value}y`;
549 }
550
551 export function topicActivityDateLabel(ms: number): string {
552 if (ms <= 0) return "";
553 const locale = getLocale();
554 const dateLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
555 return new Date(ms).toLocaleDateString(dateLocale);
556 }
557
557 lines TYPESCRIPT