Skip to content

Commit 41843a7

Browse files
committed
[IMP] dms: syntax-highlighted code/text preview via Odoo CodeEditor
1 parent 67a55f1 commit 41843a7

5 files changed

Lines changed: 163 additions & 1 deletion

File tree

dms/static/src/js/components/preview/file_preview_pane.esm.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ const _EXTENSION_MIMETYPES = {
2929
txt: "text/plain",
3030
json: "application/json",
3131
xml: "application/xml",
32-
js: "application/javascript",
32+
// Source-code extensions → dedicated code mimetypes that route to the
33+
// syntax-highlighting CodePreview (see _CODE_MIMETYPES in handlers).
34+
py: "text/x-python",
35+
js: "text/javascript",
36+
mjs: "text/javascript",
37+
cjs: "text/javascript",
38+
scss: "text/x-scss",
39+
css: "text/x-scss",
40+
sass: "text/x-scss",
41+
less: "text/x-scss",
3342
rtf: "text/rtf",
3443
csv: "text/csv",
3544
html: "text/html",

dms/static/src/js/components/preview/handlers.esm.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
33

44
import {Component, onWillStart, useState} from "@odoo/owl";
5+
import {CodeEditor} from "@web/core/code_editor/code_editor";
56
import {previewRegistry} from "./preview_registry.esm";
67

78
// ---------------------------------------------------------------------------
@@ -62,6 +63,65 @@ export class TextPreview extends Component {
6263
}
6364
}
6465

66+
// Code / source text: reuse Odoo's bundled CodeEditor (ACE) for a read-only,
67+
// syntax-highlighted preview — a step up from the raw-bytes iframe for
68+
// .py/.js/.scss/.css source files and a no-new-dependency win (the asset is
69+
// already in the backend bundle). Content is fetched once on mount; the file
70+
// extension picks the ACE mode. Odoo's bundled ACE ships only a handful of
71+
// modes (CodeEditor.MODES: python / javascript / xml / qweb / scss) — any
72+
// other mode 404s on its mode-*.js. These extensions are exactly the ones
73+
// _effectiveMimetype maps to the dedicated _CODE_MIMETYPES this handler claims;
74+
// browser-readable JSON/XML/HTML stay in TextPreview's iframe.
75+
const _ACE_MODES = {
76+
py: "python",
77+
js: "javascript",
78+
mjs: "javascript",
79+
cjs: "javascript",
80+
scss: "scss",
81+
css: "scss",
82+
sass: "scss",
83+
less: "scss",
84+
};
85+
86+
export class CodePreview extends Component {
87+
static template = "dms.preview.Code";
88+
static components = {CodeEditor};
89+
static props = fileProps;
90+
91+
setup() {
92+
this.state = useState({content: "", error: null});
93+
onWillStart(async () => {
94+
// Only the CodeEditor path needs the content string; the iframe
95+
// fallback streams it via src.
96+
if (!this.aceMode) {
97+
return;
98+
}
99+
try {
100+
const r = await fetch(this.src);
101+
if (!r.ok) {
102+
throw new Error(`HTTP ${r.status}`);
103+
}
104+
this.state.content = await r.text();
105+
} catch (e) {
106+
this.state.error = String(e.message || e);
107+
}
108+
});
109+
}
110+
111+
get aceMode() {
112+
const ext = (this.props.file.name || "").split(".").pop().toLowerCase();
113+
return _ACE_MODES[ext] || null;
114+
}
115+
116+
get src() {
117+
const ts = encodeURIComponent(this.props.file.write_date || "");
118+
return (
119+
`/web/content?id=${this.props.file.id}&model=dms.file` +
120+
`&field=content&filename_field=name&v=${ts}`
121+
);
122+
}
123+
}
124+
65125
// Markdown: client-side render to HTML, displayed in a sandboxed iframe
66126
// via srcdoc. The sandbox attribute denies scripts and top navigation so
67127
// untrusted markdown can't execute as XSS. The render covers the common
@@ -227,6 +287,18 @@ reg.add("text/*", {
227287
mt === "application/javascript",
228288
score: 0,
229289
});
290+
// Syntax-highlighted code editor for genuine source files. Claims ONLY the
291+
// dedicated source-code mimetypes that _effectiveMimetype derives from a code
292+
// extension (.py/.js/.scss/...) — NOT the browser-readable text/plain, JSON or
293+
// XML, which the browser renders fine in TextPreview's iframe (and which the
294+
// dispatch contract keeps there). Wins over text/* (score 0) for those code
295+
// mimetypes; still below Markdown (score 5) so .md renders rich.
296+
const _CODE_MIMETYPES = new Set(["text/x-python", "text/javascript", "text/x-scss"]);
297+
reg.add("text/code", {
298+
component: CodePreview,
299+
match: (mt) => _CODE_MIMETYPES.has(mt),
300+
score: 2,
301+
});
230302
// Markdown rendering wins over the generic text/* handler (score 0) so
231303
// `text/markdown` files render as formatted HTML instead of raw source.
232304
reg.add("text/markdown", {

dms/static/src/js/components/preview/handlers.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@
2626
/>
2727
</t>
2828

29+
<t t-name="dms.preview.Code">
30+
<div t-if="aceMode" class="o_dms_preview__code">
31+
<div
32+
t-if="state.error"
33+
class="o_dms_preview_pane__empty o_dms_preview_pane__empty--error"
34+
>
35+
<i class="fa fa-exclamation-triangle me-2" />
36+
<span t-out="state.error" />
37+
</div>
38+
<CodeEditor
39+
t-else=""
40+
value="state.content"
41+
mode="aceMode"
42+
readonly="true"
43+
/>
44+
</div>
45+
<iframe
46+
t-else=""
47+
class="o_dms_preview__iframe o_dms_preview__text"
48+
t-att-src="src"
49+
t-att-title="props.file.name"
50+
/>
51+
</t>
52+
2953
<t t-name="dms.preview.Markdown">
3054
<iframe
3155
class="o_dms_preview__iframe o_dms_preview__markdown"

dms/static/src/scss/file_preview_pane.scss

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,19 @@
106106
display: flex;
107107
flex-direction: column;
108108
overflow: hidden;
109+
110+
// The kanban renderer fills the space below the in-flow toolbar and
111+
// scrolls within it. Core sizes the renderer at min-height:100%, which
112+
// — added to the toolbar's height — overflows __main (overflow:hidden)
113+
// by the toolbar height: the last row and the scroll viewport's bottom
114+
// are clipped and the wheel appears dead. Force flex-fill + own scroll,
115+
// mirroring the list split below.
116+
> .o_renderer,
117+
> .o_kanban_renderer {
118+
flex: 1 1 auto;
119+
min-height: 0;
120+
overflow: auto;
121+
}
109122
}
110123
}
111124

@@ -133,6 +146,14 @@
133146
flex-direction: row;
134147
height: 100%;
135148
min-height: 0;
149+
// Fill the space beside the searchpanel and allow shrinking below the list
150+
// table's intrinsic (min-content) width. Without min-width:0 the table's
151+
// column widths keep the split wider than `.o_content`, overflowing it so
152+
// the whole content area scrolls horizontally (the searchpanel scrolls off
153+
// the left). The kanban split avoids this because its cards reflow to any
154+
// width; the list table does not, so it must scroll inside its own column.
155+
flex: 1 1 auto;
156+
min-width: 0;
136157

137158
&__list {
138159
flex: 1 1 auto;
@@ -454,6 +475,24 @@
454475
background: #fff;
455476
}
456477

478+
// ---- Code editor (syntax-highlighted source/text preview) ----
479+
.o_dms_preview__code {
480+
height: 100%;
481+
display: flex;
482+
flex-direction: column;
483+
min-height: 0;
484+
485+
// The CodeEditor (ACE) needs a sized container to fill; let it take the
486+
// pane body's height and scroll internally.
487+
.o_code_editor,
488+
.ace_editor {
489+
flex: 1 1 auto;
490+
width: 100%;
491+
min-height: 320px;
492+
border-radius: 4px;
493+
}
494+
}
495+
457496
// ---- Fallback card (office / download) ----
458497
.o_dms_preview__fallback {
459498
display: flex;

dms/static/tests/components/file_preview_pane.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,24 @@ describe("handler dispatch with effective-mimetype fallback", () => {
267267
expect(inst.handler.key).toBe("text/markdown");
268268
});
269269

270+
test("source-code extension routes to the code-editor handler", () => {
271+
// .py stored as the generic text/plain → _effectiveMimetype rewrites
272+
// it to text/x-python → CodePreview wins over the plain text iframe.
273+
const inst = _instance({
274+
state: {file: {id: 8, name: "build.py", mimetype: "text/plain"}},
275+
});
276+
expect(inst.handler.key).toBe("text/code");
277+
});
278+
279+
test("plain text with no code extension stays on the text iframe", () => {
280+
// .rst has no bundled ACE mode and no code-mimetype mapping — it must
281+
// not get hijacked by CodePreview; the browser renders it fine.
282+
const inst = _instance({
283+
state: {file: {id: 9, name: "notes.rst", mimetype: "text/plain"}},
284+
});
285+
expect(inst.handler.key).toBe("text/*");
286+
});
287+
270288
test("audio/video extension wins over a wrong image/* mimetype", () => {
271289
// The OCA dms demo stores .wav files as image/webp (a thumbnail type
272290
// leaking onto media). An image mimetype on a known audio/video

0 commit comments

Comments
 (0)