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 = () => ( -
- +
+ + {(value) => ( +
+
+ + + {value()} + +
+
+ + + + +
+
+ )} +
+ - {renderFile(contents())} + + {markdown() && display.markdown === "rendered" ? ( +
+ +
+ ) : ( + renderFile(contents()) + )} +
{language.t("common.loading")}…
diff --git a/packages/app/src/session/files/open-in-app-button.tsx b/packages/app/src/session/files/open-in-app-button.tsx index b4501ea724b1..4680b2cc802e 100644 --- a/packages/app/src/session/files/open-in-app-button.tsx +++ b/packages/app/src/session/files/open-in-app-button.tsx @@ -8,12 +8,12 @@ import { Tooltip } from "@opencode/ui/tooltip" import { useLanguage } from "@/runtime/i18n/language" import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app" -export function OpenInAppButton(props: { directory: () => string }) { +export function OpenInAppButton(props: { path: () => string; reveal?: boolean }) { const language = useLanguage() - const state = useOpenInApp({ path: props.directory }) + const state = useOpenInApp({ path: props.path }) return ( - + event.stopPropagation()}> string }) { onClick={(event) => { event.stopPropagation() if (state.opening()) return - state.openPath(state.current().id) + state.openPath(state.current().id, undefined, props.reveal) }} disabled={state.opening()} aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} @@ -52,7 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) { - state.setMenu("open", false)} /> + state.setMenu("open", false)} /> diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index ee2723007ed0..34b634641ab5 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -542,7 +542,9 @@ export function SessionSidePanel(props: { onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - + + +