file-presentation.ts6.0 KBView on GitHub import type { FileEditor, FileKind, FileListItem } from '@/modules/files/components/list/types';
/**
* The adapter: a stored node → a row.
*
* Every file surface has its own idea of what a node is — `FsNode` in the store, `TreeNode`
* in the conversation tree, `DriveNode` from Google, `AttachedFile` from mail sync,
* `AgentOutputFile` from an agent. Rather than teach `FileListRow` about five shapes, each
* surface maps into `FileListItem` here, structurally. Nothing in this file imports a
* component, so it is testable as plain functions.
*/
/** The `documentType` values the tree can hand us, mapped onto what the icon should draw. */
const KIND_BY_DOCUMENT_TYPE: Record<string, FileKind> = {
folder: 'folder',
document: 'document',
html: 'html',
table: 'table',
board: 'board',
card: 'document',
playbook: 'playbook',
agent: 'agent',
attachment: 'attachment',
};
/**
* A node's display name.
*
* Title first; failing that the last path segment, title-cased with separators removed —
* `meeting-prep/latest` becomes "Latest", not "latest" and not the raw path. This is the
* behaviour `formatDocLabel` and `nodeLabel` both already had, in two places, differently.
*/
export function fileLabel(node: { title?: string | null; path?: string | null }): string {
const titled = node.title?.trim();
if (titled) return titled;
const segment = (node.path ?? '').split('/').findLast(Boolean);
if (!segment) return 'Untitled';
return segment
.split(/[_-]/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/** `metadata.mimeType`, where the blob has one. Attachments and Drive pins do. */
function mimeTypeOf(metadata: unknown): string | null {
if (!metadata || typeof metadata !== 'object') return null;
const mime = (metadata as { mimeType?: unknown }).mimeType;
return typeof mime === 'string' ? mime : null;
}
/**
* A filesystem node (`FsNode` / `TreeNode`) → a row.
*
* `sizeBytes` comes off the node when the server put it there — `toFsNode` derives it. A
* node that predates that (an optimistic client-built one, say) falls back to unknown
* rather than the client re-deriving the discriminants, because two implementations of
* that rule would drift within a week.
*/
export function toFileListItem(
node: {
id: string;
title?: string | null;
path?: string | null;
documentType?: string | null;
emoji?: string | null;
metadata?: unknown;
sizeBytes?: unknown;
updatedAt?: Date | string | number | null;
/** `documents.inherits_grants`; false makes the folder a barrier. */
inheritsGrants?: unknown;
},
overrides: Partial<FileListItem> = {},
): FileListItem {
const documentType = node.documentType ?? 'document';
return {
id: node.id,
title: fileLabel(node),
kind: KIND_BY_DOCUMENT_TYPE[documentType] ?? 'document',
emoji: node.emoji ?? null,
mimeType: mimeTypeOf(node.metadata),
sizeBytes: typeof node.sizeBytes === 'number' ? node.sizeBytes : null,
updatedAt: node.updatedAt ?? null,
// Only an explicit `false` is a barrier. `undefined` means a node the server has not
// told us about — an optimistic client-built row, or one from a surface that projects
// fewer fields — and drawing a lock on an unknown is worse than drawing none: it
// asserts a folder is closed when nobody said so.
barrier: node.inheritsGrants === false,
...overrides,
};
}
/**
* A Google Drive row → a row.
*
* Drive tells us the size and the modified time but never who touched it, so `editedBy`
* stays null and the column renders an em dash. Filling it with the connected account's
* name would be a guess, and a wrong one for anything shared into the drive.
*/
export function driveToFileListItem(node: {
id: string;
name: string;
mimeType?: string | null;
isFolder: boolean;
sizeBytes?: number | null;
modifiedTime?: string | null;
}): FileListItem {
return {
id: node.id,
title: node.name,
kind: node.isFolder ? 'folder' : 'drive',
mimeType: node.mimeType ?? null,
// Google-native docs (a Google Doc, a Sheet) report no size at all — that is a null,
// not a zero, and the column must say "—" rather than claim the file is empty.
sizeBytes: node.sizeBytes ?? null,
updatedAt: node.modifiedTime ?? null,
};
}
/** An email attachment row (`files.listAttachedForConversation`) → a row. */
export function attachmentToFileListItem(file: {
id: string;
filename: string;
mimeType?: string | null;
sizeBytes?: number | null;
updatedAt?: Date | string | null;
}): FileListItem {
return {
id: file.id,
title: file.filename,
kind: 'attachment',
mimeType: file.mimeType ?? null,
sizeBytes: file.sizeBytes ?? null,
updatedAt: file.updatedAt ?? null,
};
}
/**
* The `documents.lastEditors` result → the `editedBy` column, keyed by document id.
*
* A document with no entry is one nothing was ever recorded for — history capture is newer
* than most of the corpus. Absent, so the column renders "—"; see the route's comment for
* why we do not fall back to the document's owner.
*/
export function editorsById(
rows: ReadonlyArray<{
documentId: string;
name: string;
kind: string;
userId?: string | null;
image?: string | null;
agentId?: string | null;
avatar?: string | null;
}>,
/**
* The viewer. Their own edits render as "you" — a column where every second row repeats
* your own name is a column that tells you nothing, and "13h ago by you" is how a person
* would say it out loud.
*/
viewerUserId?: string | null,
): Map<string, FileEditor> {
const byId = new Map<string, FileEditor>();
for (const row of rows) {
const isViewer = !!viewerUserId && row.userId === viewerUserId;
byId.set(row.documentId, {
name: isViewer ? 'you' : row.name,
kind: row.kind === 'human' || row.kind === 'system' ? row.kind : 'agent',
image: row.image ?? null,
agentId: row.agentId ?? null,
avatar: row.avatar ?? null,
});
}
return byId;
}