feat(ui): add markdown preview to file viewer (#352)

Fixes #331

## Summary
- add an optional Markdown preview toggle for markdown files in the
Files tab
- add a word-wrap toggle for the source editor
- escape raw HTML in preview mode and limit preview to plain Markdown
file extensions

## Why
The Files tab only showed raw source, which makes Markdown files harder
to read quickly.

This change adds a lightweight preview/source switch without introducing
a larger viewer registry.

## What Changed
-
`packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx`
  - added `Preview Markdown` / `Show source` toggle for markdown files
  - added a word-wrap toggle for the Monaco source viewer
  - restricted preview mode to plain Markdown extensions
  - escaped raw HTML in markdown preview mode
- `packages/ui/src/components/file-viewer/monaco-file-viewer.tsx`
  - added configurable word-wrap support
- `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`
- moved file-viewer word-wrap state up so it persists across tab
switches
- `packages/ui/src/components/instance/shell/storage.ts`
  - added storage key for file-viewer word wrap
- `packages/ui/src/lib/i18n/messages/*/instance.ts`
  - added strings for preview/source and word-wrap controls

## Validation
- `npm run build --workspace @codenomad/ui`
This commit is contained in:
Pascal André
2026-04-26 22:24:19 +02:00
committed by GitHub
parent 27f9c76a94
commit 0ba1371348
13 changed files with 298 additions and 294 deletions

317
package-lock.json generated
View File

@@ -3189,84 +3189,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@neuralnomads/codenomad": {
"resolved": "packages/server",
"link": true
@@ -3307,47 +3229,6 @@
"node": ">= 8"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.14.19",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.19.tgz",
"integrity": "sha512-g0C8Viocybmet7nBqJK/1xrQnacRS1f30VmqRTPScPmWz+4knIZzc2TEQp8+920sN8rB6BuoGwfBUVRXJmavhQ==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.14.19",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.101",
"@opentui/solid": ">=0.1.101"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": {
"version": "1.14.19",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.19.tgz",
"integrity": "sha512-9sTGsi8/HlBBeaWfsUjdJ2yi/SqpRvqSld0IFXc3ldaPb1w1uIPvgCGzhlHYQtqatXxSaX5lTN7zpudMaE21aw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@opencode-ai/plugin/node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
@@ -3952,12 +3833,6 @@
"solid-js": "^1.8.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@suid/base": {
"version": "0.11.0",
"license": "MIT",
@@ -6028,7 +5903,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -6213,24 +6088,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"dev": true,
@@ -6742,28 +6599,6 @@
"license": "MIT",
"optional": true
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"license": "MIT"
@@ -6994,12 +6829,6 @@
"node": ">=14"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"license": "MIT",
@@ -7809,15 +7638,6 @@
"version": "2.0.4",
"license": "ISC"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -8487,12 +8307,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/lazy-val": {
"version": "1.0.5",
"dev": true,
@@ -8956,43 +8770,6 @@
"version": "2.1.3",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"dev": true,
@@ -9068,21 +8845,6 @@
"node": ">= 6.13.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"dev": true,
@@ -9689,22 +9451,6 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"license": "MIT",
@@ -11477,15 +11223,6 @@
"node": ">=0.6"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@@ -11951,19 +11688,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"dev": true,
@@ -13463,7 +13187,44 @@
"version": "0.5.0",
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.14.19"
"@opencode-ai/plugin": "1.3.7"
}
},
"packages/opencode-config/node_modules/@opencode-ai/plugin": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz",
"integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.3.7",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.92",
"@opentui/solid": ">=0.1.92"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"packages/opencode-config/node_modules/@opencode-ai/sdk": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz",
"integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==",
"license": "MIT"
},
"packages/opencode-config/node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/server": {

View File

@@ -9,6 +9,7 @@ interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
wordWrap?: "on" | "off"
onSave?: (content: string) => void
onContentChange?: (content: string) => void
}
@@ -84,6 +85,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!ready() || !editor) return
editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" })
})
createEffect(() => {
if (!ready() || !monaco || !editor) return
const languageId = inferMonacoLanguageId(monaco, props.path)

View File

@@ -42,6 +42,7 @@ import {
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_FILES_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
@@ -131,6 +132,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [filesWordWrapMode, setFilesWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -254,6 +258,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -912,6 +921,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
wordWrapMode={filesWordWrapMode}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
@@ -919,6 +929,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
onWordWrapModeChange={setFilesWordWrapMode}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}

View File

@@ -1,16 +1,23 @@
import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { Copy, RefreshCw, Save, Search } from "lucide-solid"
import { Copy, RefreshCw, Save, Search, WrapText } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel"
import { Markdown } from "../../../../markdown"
import { copyToClipboard } from "../../../../../lib/clipboard"
import { showToastNotification } from "../../../../../lib/notifications"
import { useTheme } from "../../../../../lib/theme"
const LazyMonacoFileViewer = lazy(() =>
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
function isMarkdownPath(path: string | null | undefined): boolean {
if (!path) return false
return /\.(md|markdown|mdown|mkdn)$/i.test(path)
}
interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -25,6 +32,7 @@ interface FilesTabProps {
browserSelectedError: Accessor<string | null>
browserSelectedDirty: Accessor<boolean>
browserSelectedSaving: Accessor<boolean>
wordWrapMode: Accessor<"on" | "off">
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
@@ -34,6 +42,7 @@ interface FilesTabProps {
onRefresh: () => void
onSave: (content: string) => void
onContentChange: (content: string) => void
onWordWrapModeChange: (mode: "on" | "off") => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -45,6 +54,9 @@ interface FilesTabProps {
const FilesTab: Component<FilesTabProps> = (props) => {
const [filterQuery, setFilterQuery] = createSignal("")
const { isDark } = useTheme()
const [markdownPreviewEnabled, setMarkdownPreviewEnabled] = createSignal(false)
let markdownPreviewRef: HTMLDivElement | undefined
createEffect(() => {
props.browserPath()
@@ -78,6 +90,14 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const listEmptyMessage = () =>
normalizedQuery() ? props.t("instanceShell.filesShell.search.empty") : props.t("instanceShell.filesShell.listEmpty")
const selectedMarkdownFile = createMemo(() => isMarkdownPath(props.browserSelectedPath()))
const showingMarkdownPreview = createMemo(() => selectedMarkdownFile() && markdownPreviewEnabled())
createEffect(() => {
if (!selectedMarkdownFile()) {
setMarkdownPreviewEnabled(false)
}
})
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
@@ -94,6 +114,11 @@ const FilesTab: Component<FilesTabProps> = (props) => {
})
}
createEffect(() => {
if (!showingMarkdownPreview()) return
requestAnimationFrame(() => markdownPreviewRef?.focus())
})
const FileList: Component = () => (
<>
<div class="px-2 py-2 border-b border-base">
@@ -182,6 +207,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</>
)
const handleMarkdownPreviewKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return
if (props.browserSelectedSaving() || !props.browserSelectedDirty()) return
event.preventDefault()
handleSave()
}
const renderContent = (): JSX.Element => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
@@ -192,7 +224,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco">
<div class={showingMarkdownPreview() ? "file-viewer-content" : "file-viewer-content file-viewer-content--monaco"}>
<Show
when={props.browserSelectedLoading()}
fallback={
@@ -212,21 +244,37 @@ const FilesTab: Component<FilesTabProps> = (props) => {
}
>
{(payload) => (
<Suspense
<Show
when={showingMarkdownPreview()}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoFileViewer
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
wordWrap={props.wordWrapMode()}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
</Suspense>
}
>
<LazyMonacoFileViewer
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
</Suspense>
<div
ref={markdownPreviewRef}
class="h-full outline-none"
tabIndex={0}
onKeyDown={handleMarkdownPreviewKeyDown}
onMouseDown={() => markdownPreviewRef?.focus()}
>
<Markdown part={{ type: "text", text: payload().content }} isDark={isDark()} escapeRawHtml />
</div>
</Show>
)}
</Show>
}
@@ -262,13 +310,33 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class={`file-viewer-toolbar-button${showingMarkdownPreview() ? " active" : ""}`}
disabled={!selectedMarkdownFile()}
style={{ "margin-inline-start": "auto" }}
onClick={() => selectedMarkdownFile() && setMarkdownPreviewEnabled((prev) => !prev)}
>
{showingMarkdownPreview()
? props.t("instanceShell.filesShell.showSource")
: props.t("instanceShell.filesShell.previewMarkdown")}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${props.wordWrapMode() === "on" ? " active" : ""}`}
title={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
aria-label={props.wordWrapMode() === "on" ? props.t("instanceShell.filesShell.disableWordWrap") : props.t("instanceShell.filesShell.enableWordWrap")}
disabled={showingMarkdownPreview()}
onClick={() => props.onWordWrapModeChange(props.wordWrapMode() === "on" ? "off" : "on")}
>
<WrapText class="h-4 w-4" />
</button>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
style={{ "margin-inline-start": "auto" }}
onClick={handleSave}
>
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>

View File

@@ -28,6 +28,7 @@ export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
export const RIGHT_PANEL_FILES_WORD_WRAP_KEY = "opencode-session-right-panel-files-word-wrap-v1"
export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

@@ -158,6 +158,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Copy path",
"instanceShell.filesShell.toast.copyPathSuccess": "Copied path",
"instanceShell.filesShell.toast.copyPathError": "Failed to copy path",
"instanceShell.filesShell.previewMarkdown": "Preview Markdown",
"instanceShell.filesShell.showSource": "Show source",
"instanceShell.filesShell.enableWordWrap": "Enable word wrap",
"instanceShell.filesShell.disableWordWrap": "Disable word wrap",
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
"instanceShell.diff.showFull": "Show full file",
"instanceShell.diff.switchToSplit": "Switch to split view",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Copiar ruta",
"instanceShell.filesShell.toast.copyPathSuccess": "Ruta copiada",
"instanceShell.filesShell.toast.copyPathError": "No se pudo copiar la ruta",
"instanceShell.filesShell.previewMarkdown": "Vista previa Markdown",
"instanceShell.filesShell.showSource": "Mostrar fuente",
"instanceShell.filesShell.enableWordWrap": "Activar ajuste de línea",
"instanceShell.filesShell.disableWordWrap": "Desactivar ajuste de línea",
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Copier le chemin",
"instanceShell.filesShell.toast.copyPathSuccess": "Chemin copié",
"instanceShell.filesShell.toast.copyPathError": "Impossible de copier le chemin",
"instanceShell.filesShell.previewMarkdown": "Aperçu Markdown",
"instanceShell.filesShell.showSource": "Afficher la source",
"instanceShell.filesShell.enableWordWrap": "Activer le retour à la ligne",
"instanceShell.filesShell.disableWordWrap": "Désactiver le retour à la ligne",
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

View File

@@ -142,6 +142,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "העתק נתיב",
"instanceShell.filesShell.toast.copyPathSuccess": "הנתיב הועתק",
"instanceShell.filesShell.toast.copyPathError": "העתקת הנתיב נכשלה",
"instanceShell.filesShell.previewMarkdown": "תצוגת Markdown",
"instanceShell.filesShell.showSource": "הצג מקור",
"instanceShell.filesShell.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.filesShell.disableWordWrap": "כבה גלישת מילים",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "パスをコピー",
"instanceShell.filesShell.toast.copyPathSuccess": "パスをコピーしました",
"instanceShell.filesShell.toast.copyPathError": "パスをコピーできませんでした",
"instanceShell.filesShell.previewMarkdown": "Markdown プレビュー",
"instanceShell.filesShell.showSource": "ソースを表示",
"instanceShell.filesShell.enableWordWrap": "折り返しを有効化",
"instanceShell.filesShell.disableWordWrap": "折り返しを無効化",
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "Скопировать путь",
"instanceShell.filesShell.toast.copyPathSuccess": "Путь скопирован",
"instanceShell.filesShell.toast.copyPathError": "Не удалось скопировать путь",
"instanceShell.filesShell.previewMarkdown": "Предпросмотр Markdown",
"instanceShell.filesShell.showSource": "Показать исходник",
"instanceShell.filesShell.enableWordWrap": "Включить перенос строк",
"instanceShell.filesShell.disableWordWrap": "Отключить перенос строк",
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.",

View File

@@ -155,6 +155,10 @@ export const instanceMessages = {
"instanceShell.filesShell.actions.copyPath": "复制路径",
"instanceShell.filesShell.toast.copyPathSuccess": "路径已复制",
"instanceShell.filesShell.toast.copyPathError": "无法复制路径",
"instanceShell.filesShell.previewMarkdown": "Markdown 预览",
"instanceShell.filesShell.showSource": "显示源码",
"instanceShell.filesShell.enableWordWrap": "启用自动换行",
"instanceShell.filesShell.disableWordWrap": "禁用自动换行",
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。",

View File

@@ -16,6 +16,135 @@ let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
const ALLOWED_RAW_HTML_TAGS = new Set([
"a",
"blockquote",
"br",
"code",
"del",
"details",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"kbd",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
])
const DROP_RAW_HTML_TAGS = new Set(["script", "style", "iframe", "object", "embed", "meta", "link"])
function sanitizeUrlAttribute(tagName: string, attrName: string, value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return null
if (attrName === "src" && tagName === "img") {
if (/^(https?:|data:image\/|\/|\.\/|\.\.\/|#)/i.test(trimmed)) return trimmed
return null
}
if (attrName === "href" && tagName === "a") {
if (/^(https?:|mailto:|\/|\.\/|\.\.\/|#)/i.test(trimmed)) return trimmed
return null
}
return null
}
function sanitizeRawHtmlFragment(html: string): string {
const decoded = decodeHtmlEntities(html)
if (typeof document === "undefined") {
return escapeHtml(decoded)
}
const template = document.createElement("template")
template.innerHTML = decoded
const sanitizeElement = (element: Element) => {
const tagName = element.tagName.toLowerCase()
if (DROP_RAW_HTML_TAGS.has(tagName)) {
element.remove()
return
}
if (!ALLOWED_RAW_HTML_TAGS.has(tagName)) {
element.replaceWith(...Array.from(element.childNodes))
return
}
for (const attr of Array.from(element.attributes)) {
const attrName = attr.name.toLowerCase()
if (attrName.startsWith("on") || attrName === "style") {
element.removeAttribute(attr.name)
continue
}
if (attrName === "href" || attrName === "src") {
const sanitized = sanitizeUrlAttribute(tagName, attrName, attr.value)
if (sanitized) {
element.setAttribute(attr.name, sanitized)
continue
}
element.removeAttribute(attr.name)
continue
}
if (
attrName === "alt" ||
attrName === "title" ||
attrName === "width" ||
attrName === "height" ||
attrName === "open" ||
attrName === "id" ||
attrName === "class" ||
attrName === "name" ||
attrName.startsWith("aria-") ||
attrName.startsWith("data-")
) {
continue
}
element.removeAttribute(attr.name)
}
if (tagName === "a") {
element.setAttribute("target", "_blank")
element.setAttribute("rel", "noopener noreferrer")
}
}
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT)
const elements: Element[] = []
while (walker.nextNode()) {
elements.push(walker.currentNode as Element)
}
for (const element of elements.reverse()) {
sanitizeElement(element)
}
return template.innerHTML
}
// Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>()
const queuedLanguages = new Set<string>()
@@ -318,7 +447,7 @@ function setupRenderer(isDark: boolean) {
return html
}
return escapeHtml(decodeHtmlEntities(html))
return sanitizeRawHtmlFragment(html)
}
marked.use({ renderer })