Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/mobile/src/features/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter";
import {
Markdown,
type CustomRenderers,
Expand All @@ -20,6 +21,7 @@ import {
SelectableMarkdownText,
type NativeMarkdownTextStyle,
} from "../../native/SelectableMarkdownText";
import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable";

interface MarkdownPreviewStyles {
readonly theme: PartialMarkdownTheme;
Expand Down Expand Up @@ -187,6 +189,7 @@ export function FileMarkdownPreview(props: {
}
}, [props.onRefresh]);
const styles = useMarkdownPreviewStyles();
const frontmatter = useMemo(() => parseMarkdownFrontmatter(props.markdown), [props.markdown]);
const onLinkPress = useCallback((href: string) => {
void tryOpenExternalUrl(href, "markdown-link");
}, []);
Expand All @@ -205,9 +208,12 @@ export function FileMarkdownPreview(props: {
}
>
<View className="mx-auto w-full max-w-[760px]">
{frontmatter.entries.length > 0 ? (
<MarkdownFrontmatterTable entries={frontmatter.entries} />
) : null}
{hasNativeSelectableMarkdownText() ? (
<SelectableMarkdownText
markdown={props.markdown}
markdown={frontmatter.body}
onLinkPress={onLinkPress}
textStyle={styles.nativeTextStyle}
/>
Expand All @@ -218,7 +224,7 @@ export function FileMarkdownPreview(props: {
styles={styles.styles}
theme={styles.theme}
>
{props.markdown}
{frontmatter.body}
</Markdown>
)}
</View>
Expand Down
122 changes: 122 additions & 0 deletions apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter";
import { useState } from "react";
import { ScrollView, Text as NativeText, View } from "react-native";

import { useThemeColor } from "../../lib/useThemeColor";

const MIN_KEY_COLUMN_WIDTH = 160;
const MIN_VALUE_COLUMN_WIDTH = 400;

function MarkdownFrontmatterList({
items,
textColor,
}: {
readonly items: ReadonlyArray<string>;
readonly textColor: string;
}) {
const occurrences = new Map<string, number>();

return (
<View className="flex-row flex-wrap gap-1">
{items.map((item) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);

return (
<View
key={JSON.stringify([item, occurrence])}
className="max-w-full rounded-full border border-secondary-border bg-secondary px-2.5 py-1"
>
<NativeText className="font-t3-regular text-sm" style={{ color: textColor }}>
{item}
</NativeText>
</View>
);
})}
</View>
);
}

export function MarkdownFrontmatterTable({
entries,
}: {
readonly entries: ReadonlyArray<MarkdownFrontmatterEntry>;
}) {
const textColor = String(useThemeColor("--color-md-body"));
const strongColor = String(useThemeColor("--color-md-strong"));
const codeColor = String(useThemeColor("--color-md-code-text"));
const [keyWidths, setKeyWidths] = useState<ReadonlyMap<string, number>>(() => new Map());
let measuredKeyColumnWidth = MIN_KEY_COLUMN_WIDTH;
let hasEveryKeyWidth = true;
for (const entry of entries) {
const keyWidth = keyWidths.get(entry.key);
if (keyWidth === undefined) {
hasEveryKeyWidth = false;
break;
}
measuredKeyColumnWidth = Math.max(measuredKeyColumnWidth, keyWidth);
}
const keyColumnWidth = hasEveryKeyWidth ? measuredKeyColumnWidth : null;

return (
<ScrollView
horizontal
className="mb-6"
contentContainerStyle={{ minWidth: "100%" }}
showsHorizontalScrollIndicator={false}
>
<View
className="flex-1 overflow-hidden border border-border"
style={{ minWidth: (keyColumnWidth ?? MIN_KEY_COLUMN_WIDTH) + MIN_VALUE_COLUMN_WIDTH }}
>
{entries.map((entry, index) => (
<View
key={entry.key}
className={index === 0 ? "flex-row" : "flex-row border-t border-border"}
>
<View
className="min-w-40 shrink-0 items-end justify-center border-r border-border bg-card px-3 py-2"
style={keyColumnWidth === null ? undefined : { width: keyColumnWidth }}
onLayout={
keyColumnWidth === null
? (event) => {
const measuredWidth = Math.ceil(event.nativeEvent.layout.width);
setKeyWidths((current) => {
if (current.get(entry.key) === measuredWidth) {
return current;
}
const next = new Map(current);
next.set(entry.key, measuredWidth);
return next;
});
}
: undefined
}
>
<NativeText
numberOfLines={1}
className="font-t3-bold text-sm"
style={{ color: strongColor }}
>
{entry.key}
</NativeText>
</View>
<View className="min-w-0 flex-1 px-3 py-2">
{entry.value.kind === "text" ? (
<NativeText className="font-t3-regular text-sm" style={{ color: textColor }}>
{entry.value.text}
</NativeText>
) : entry.value.kind === "list" ? (
<MarkdownFrontmatterList items={entry.value.items} textColor={textColor} />
) : (
<NativeText className="font-mono text-xs" style={{ color: codeColor }}>
{entry.value.source}
</NativeText>
)}
</View>
</View>
))}
</View>
</ScrollView>
);
}
42 changes: 27 additions & 15 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter";
import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
import * as Schema from "effect/Schema";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -58,6 +59,7 @@ import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRev
import { fileBreadcrumbs } from "./filePath";
import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
import { FileSaveCoordinator } from "./fileSaveCoordinator";
import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable";
import {
confirmProjectFileQueryData,
getOptimisticProjectFileQueryData,
Expand Down Expand Up @@ -724,24 +726,34 @@ function RenderedMarkdownSurface({
relativePath,
onPendingChange,
});
const frontmatter = useMemo(() => parseMarkdownFrontmatter(contents), [contents]);

return (
<ScrollArea className="min-h-0 flex-1">
<ChatMarkdown
text={contents}
cwd={cwd}
threadRef={threadRef}
className="mx-auto max-w-4xl px-6 py-5"
onTaskListChange={({ markerOffset, checked }) => {
const currentContents =
getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
contents;
const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked);
if (nextContents === currentContents) return;
setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
saveCoordinator.change(nextContents);
}}
/>
<div className="mx-auto max-w-4xl px-6 py-5">
{frontmatter.entries.length > 0 ? (
<MarkdownFrontmatterTable entries={frontmatter.entries} />
) : null}
<ChatMarkdown
text={frontmatter.body}
cwd={cwd}
threadRef={threadRef}
className={frontmatter.entries.length > 0 ? "mt-8" : ""}
onTaskListChange={({ markerOffset, checked }) => {
const currentContents =
getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ??
contents;
const nextContents = setMarkdownTaskChecked(
currentContents,
frontmatter.bodyOffset + markerOffset,
checked,
);
if (nextContents === currentContents) return;
setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
saveCoordinator.change(nextContents);
}}
/>
</div>
</ScrollArea>
);
}
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/components/files/MarkdownFrontmatterTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter";

import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";

function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray<string> }) {
const occurrences = new Map<string, number>();

return (
<span className="flex flex-wrap gap-1">
{items.map((item) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);

return (
<Badge key={JSON.stringify([item, occurrence])} variant="outline">
{item}
</Badge>
);
})}
</span>
);
}

export function MarkdownFrontmatterTable({
entries,
}: {
readonly entries: ReadonlyArray<MarkdownFrontmatterEntry>;
}) {
return (
<ScrollArea
chainVerticalScroll
scrollFade
hideScrollbars
className="w-full max-w-full rounded-none"
>
<table className="markdown-table">
<tbody>
{entries.map((entry) => (
<tr key={entry.key}>
<th
scope="row"
className="w-px whitespace-nowrap bg-muted/30 text-right align-middle font-semibold text-foreground"
>
{entry.key}
</th>
<td className="align-top text-foreground/80">
{entry.value.kind === "text" ? (
<span className="whitespace-pre-wrap">{entry.value.text}</span>
) : entry.value.kind === "list" ? (
<MarkdownFrontmatterList items={entry.value.items} />
) : (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed">
{entry.value.source}
</pre>
)}
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
);
}
27 changes: 21 additions & 6 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2343,29 +2343,44 @@ code {
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
characters and defeat the overflow — restore word-boundary wrapping so the
min column width is the longest word. */
.chat-markdown table {
.chat-markdown table,
.markdown-table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
font-size: 0.75rem;
}

.chat-markdown table {
min-width: max-content;
overflow-wrap: normal;
word-break: normal;
font-size: 0.75rem;
}

.chat-markdown th,
.chat-markdown td {
.chat-markdown td,
.markdown-table th,
.markdown-table td {
padding: 0.45rem 0.75rem;
}

.chat-markdown th,
.chat-markdown td,
.markdown-table td {
text-align: left;
}

.chat-markdown thead th {
.chat-markdown thead th,
.markdown-table thead th {
border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
padding-block: 0.55rem;
font-weight: 600;
white-space: nowrap;
}

.chat-markdown tbody td {
.chat-markdown tbody th,
.chat-markdown tbody td,
.markdown-table tbody th,
.markdown-table tbody td {
border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
}

Expand Down
16 changes: 16 additions & 0 deletions docs/user/file-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# File previews

Markdown files can switch between source and rendered views on web and desktop. Mobile opens
Markdown files in the rendered view.

## YAML frontmatter

Rendered Markdown recognizes YAML frontmatter when the file starts with a `---` line, ends the
frontmatter with another `---` line, and contains a YAML mapping. T3 Code displays the mapping as a
metadata table above the Markdown body.

Scalar values appear as text. Arrays containing only scalar values appear as pills. Nested objects
and arrays remain formatted as YAML.

Invalid YAML, an unclosed frontmatter block, or a frontmatter value that is not a mapping remains in
the Markdown body unchanged.
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"types": "./src/markdownImages.ts",
"default": "./src/markdownImages.ts"
},
"./markdown-frontmatter": {
"types": "./src/markdownFrontmatter.ts",
"default": "./src/markdownFrontmatter.ts"
},
"./errors": {
"types": "./src/errors/index.ts",
"default": "./src/errors/index.ts"
Expand Down
Loading
Loading