diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index c4fdb2ff3..92b194974 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,5 +1,7 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" +export { SessionPreviewTab } from "./session-preview-tab" +export { PanelMenu } from "./panel-menu" export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" diff --git a/packages/app/src/components/session/panel-menu.tsx b/packages/app/src/components/session/panel-menu.tsx new file mode 100644 index 000000000..f597b5415 --- /dev/null +++ b/packages/app/src/components/session/panel-menu.tsx @@ -0,0 +1,55 @@ +import { For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface PanelMenuItem { + id: string + label: string + icon: string + available: () => boolean + active?: () => boolean +} + +// ─── Component ────────────────────────────────────────────────────────────── + +export function PanelMenu(props: { + items: PanelMenuItem[] + onSelect: (id: string) => void + v2?: boolean +}) { + return ( + + + + + + + item.available())}> + {(item) => ( + props.onSelect(item.id)} + > + + {item.label} + + + + + )} + + + + + ) +} diff --git a/packages/app/src/components/session/session-preview-tab.tsx b/packages/app/src/components/session/session-preview-tab.tsx new file mode 100644 index 000000000..5e987f867 --- /dev/null +++ b/packages/app/src/components/session/session-preview-tab.tsx @@ -0,0 +1,386 @@ +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { SegmentedControlV2, SegmentedControlItemV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface PreviewFileEntry { + path: string + relativePath: string + basename: string + extension: ".md" + changeType: "added" | "modified" +} + +interface PreviewFileState { + mode: "preview" | "raw" + scrollPosition?: number + unsavedContent?: string +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +/** + * Convert ```math fenced code blocks (GitHub-flavored) to $$...$$ display math + * blocks that the Markdown component's KaTeX extension understands. + */ +function preprocessMarkdown(text: string): string { + return text.replace(/```math\n([\s\S]*?)```/g, (_match, body: string) => `$$\n${body.trim()}\n$$`) +} + +export function SessionPreviewTab(props: { diffs: () => Array<{ file: string; status?: string }> }) { + const sdk = useSDK() + const serverSDK = useServerSDK() + const [selectedFile, setSelectedFile] = createSignal(undefined) + const [fileStates, setFileStates] = createStore>({}) + const [fileContent, setFileContent] = createSignal("") + const [loading, setLoading] = createSignal(false) + const [zoom, setZoom] = createSignal(100) + + const zoomIn = () => setZoom((z) => Math.min(z + 10, 200)) + const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + + // Filter diffs to only .md files + const markdownFiles = createMemo((): PreviewFileEntry[] => { + return props + .diffs() + .filter((d) => d.file.endsWith(".md")) + .map((d) => { + const parts = d.file.split("/") + const basename = parts[parts.length - 1] + // relativePath: strip leading ~/ prefix + const relativePath = d.file.startsWith("~/") ? d.file.slice(2) : d.file + return { + path: d.file, + relativePath, + basename, + extension: ".md" as const, + changeType: (d.status === "added" ? "added" : "modified") as "added" | "modified", + } + }) + }) + + // Load file content when a file is selected + createEffect( + on(selectedFile, (path) => { + if (!path) return + setLoading(true) + + // Resolve the actual filesystem path from the display path + const fsPath = path.startsWith("~/") + ? path.replace("~", process.env.HOME ?? "") + : path + + sdk() + .client.file.read({ path: fsPath }) + .then((result) => { + const content = result.data + if (content && content.type === "text") { + setFileContent(content.content) + } + }) + .catch(() => { + setFileContent("") + }) + .finally(() => { + setLoading(false) + }) + }), + ) + + const currentMode = createMemo(() => { + const path = selectedFile() + if (!path) return "preview" + return fileStates[path]?.mode ?? "preview" + }) + + const goBack = () => { + setSelectedFile(undefined) + } + + // ─── Raw Editor Save ──────────────────────────────────────────────────── + + let saveTimer: ReturnType | undefined + const [saveStatus, setSaveStatus] = createSignal<"idle" | "saving" | "saved">("idle") + let savedTimer: ReturnType | undefined + + const saveFile = (path: string, content: string) => { + const fsPath = path.startsWith("~/") + ? path.replace("~", process.env.HOME ?? "") + : path + + const baseUrl = serverSDK().url + if (!baseUrl) return + + setSaveStatus("saving") + + // POST to the file write endpoint + fetch(new URL("/file/write", baseUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: fsPath, content }), + }) + .then(() => { + setSaveStatus("saved") + if (savedTimer) clearTimeout(savedTimer) + savedTimer = setTimeout(() => setSaveStatus("idle"), 2000) + }) + .catch(() => { + setSaveStatus("idle") + }) + } + + const debouncedSave = (path: string, content: string) => { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveFile(path, content), 1000) + } + + const immediateSave = () => { + const path = selectedFile() + if (!path) return + const content = fileStates[path]?.unsavedContent + if (content !== undefined) { + if (saveTimer) clearTimeout(saveTimer) + saveFile(path, content) + setFileStates(path, { ...fileStates[path], unsavedContent: undefined }) + } + } + + const handleRawEdit = (content: string) => { + const path = selectedFile() + if (!path) return + setFileStates(path, { ...fileStates[path], unsavedContent: content }) + setFileContent(content) + debouncedSave(path, content) + } + + onCleanup(() => { + if (saveTimer) clearTimeout(saveTimer) + if (savedTimer) clearTimeout(savedTimer) + }) + + // ─── Render ───────────────────────────────────────────────────────────── + + return ( +
+ } + > + {(path) => ( +
+ {/* Header with back button, mode toggle */} +
+ +
+ {markdownFiles().find((f) => f.path === path())?.basename ?? path()} +
+ + + {saveStatus() === "saving" ? "Saving..." : "Saved"} + + + {/* Zoom control: [100% | - +] */} +
+ { + const val = parseInt(e.currentTarget.value) + if (!isNaN(val) && val >= 50 && val <= 200) setZoom(val) + }} + onBlur={(e) => { + e.currentTarget.value = `${zoom()}%` + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur() + } + }} + /> +
+ + +
+
+ {/* Mode toggle */} + { + if (value !== "preview" && value !== "raw") return + const p = selectedFile() + if (p) setFileStates(p, { ...fileStates[p], mode: value }) + }} + class="!w-auto" + aria-label="View mode" + > + + + + + + + + + + + +
+ + {/* Content area */} +
+ Loading...
}> + + } + > +
+ +
+
+ +
+
+ )} + + + ) +} + +// ─── File List ────────────────────────────────────────────────────────────── + +function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: string) => void }) { + const copyToClipboard = (text: string) => { + if (!writeClipboardViaBridge(text)) { + void navigator.clipboard.writeText(text) + } + } + + return ( +
+ 0} + fallback={ +
+ No markdown files modified in this session +
+ } + > +
+ + {(file) => ( + + props.onSelect(file.path)} + > + + {file.changeType === "added" ? "A" : "M"} + +
+
{file.basename}
+
{file.relativePath}
+
+
+ + + copyToClipboard(file.basename)}>Copy filename + { + const fullPath = file.path.startsWith("~/") + ? file.path.replace("~", process.env.HOME ?? "") + : file.path + copyToClipboard(fullPath) + }}>Copy full path + + +
+ )} +
+
+
+
+ ) +} + +// ─── Raw Editor ───────────────────────────────────────────────────────────── + +function RawEditor(props: { content: string; onEdit: (content: string) => void; onSave: () => void; zoom: number }) { + let textareaRef: HTMLTextAreaElement | undefined + + return ( +