diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 208ba7f3b69d..2ee8463101ce 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -55,7 +55,10 @@ test("opens and searches project files inline", async ({ page }) => { { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, ] }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + fileContent: (path) => ({ + type: "text", + content: path === "README.md" ? "# Rendered README\n\ncontents:README.md" : `contents:${path}`, + }), findFiles: (input) => { searches.push(input) return input.query === "nested" ? ["src/nested.ts"] : [] @@ -126,7 +129,19 @@ test("opens and searches project files inline", async ({ page }) => { await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px") await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px") await expect(sidebarToggle).toBeEnabled() + await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(panel.getByTitle("README.md")).toBeVisible() + const viewSource = panel.getByRole("button", { name: "View Source" }) + await expect(viewSource).toBeVisible() + await expect.poll(() => viewSource.evaluate((button) => button.scrollWidth <= button.clientWidth)).toBe(true) + await viewSource.click() + await expect(panel.getByRole("heading", { name: "Rendered README" })).toHaveCount(0) + await expect(panel.getByText("# Rendered README", { exact: true })).toBeVisible() + const viewRendered = panel.getByRole("button", { name: "View Rendered" }) + await expect.poll(() => viewRendered.evaluate((button) => button.scrollWidth <= button.clientWidth)).toBe(true) + await viewRendered.click() + await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible() await expect(sidebar).toHaveCount(0) const missingReadPattern = "**/api/fs/read/README.md*" @@ -173,6 +188,8 @@ test("opens and searches project files inline", async ({ page }) => { await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px") await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() + await expect(panel.getByTitle("src/nested.ts")).toBeVisible() + await expect(panel.getByRole("button", { name: /View (Source|Rendered)/ })).toHaveCount(0) expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index c82892415a7f..91526a596410 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -796,6 +796,8 @@ export const dict = { "session.review.noChanges": "No changes", "session.review.noUncommittedChanges": "No uncommitted changes yet", "session.review.noBranchChanges": "No branch changes yet", + "session.files.markdown.viewSource": "View Source", + "session.files.markdown.viewRendered": "View Rendered", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/session/files/file-tabs.tsx b/packages/app/src/session/files/file-tabs.tsx index 1446426840e8..401a4f934d61 100644 --- a/packages/app/src/session/files/file-tabs.tsx +++ b/packages/app/src/session/files/file-tabs.tsx @@ -1,9 +1,12 @@ -import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" import type { FileSearchHandle } from "@opencode/session-ui/file" +import { Markdown } from "@opencode/session-ui/markdown" +import { Button } from "@opencode/ui/button" import { useFileComponent } from "@opencode/ui/context/file" +import { FileIcon } from "@opencode/ui/file-icon" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge" import { createLineCommentControllerV2 } from "@opencode/session-ui/v2/line-comment-annotations-v2" import { sampledChecksum } from "@opencode/util/encode" @@ -19,6 +22,9 @@ import { useComposerState } from "@/composer/persistence" import { getSessionHandoff } from "@/session/handoff" import { useSessionLayout } from "@/session/session-layout" import { createSessionTabs } from "@/session/helpers" +import { OpenInAppButton } from "@/session/files/open-in-app-button" +import { resolveOpenInAppPath } from "@/session/files/open-in-app-path" +import { useWorkspaceLocation } from "@/workspaces/location" type SessionFileViewProps = { tab: string @@ -182,6 +188,7 @@ export function SessionFileView(props: SessionFileViewProps) { const language = useLanguage() const prompt = useComposerState() const fileComponent = useFileComponent() + const location = useWorkspaceLocation() const { sessionKey, tabs, view } = useSessionLayout() const activeFileTab = createSessionTabs({ tabs, @@ -198,6 +205,9 @@ export function SessionFileView(props: SessionFileViewProps) { } const path = createMemo(() => file.pathFromTab(props.tab)) + const markdown = createMemo(() => path()?.toLowerCase().endsWith(".md") ?? false) + const absolutePath = createMemo(() => resolveOpenInAppPath(location().directory, path() ?? "")) + const [display, setDisplay] = createStore({ markdown: "rendered" as "rendered" | "source" }) const state = createMemo(() => { const p = path() if (!p) return @@ -451,10 +461,47 @@ export function SessionFileView(props: SessionFileViewProps) { ) const content = () => ( -