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