From 448effb25df6cf025ccd9df97b23e2c7e5c83b7b Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sat, 21 Jun 2025 14:15:59 +0800 Subject: [PATCH 01/19] feat: add plugin-based file rendering system with 3D file preview support --- package-lock.json | 51 +++++++++++++++++ package.json | 1 + templates/repo/view_file.tmpl | 2 +- web_src/css/file-view.css | 71 +++++++++++++++++++++++ web_src/css/index.css | 2 + web_src/js/features/file-view.ts | 59 +++++++++++++++++++ web_src/js/index.ts | 5 ++ web_src/js/modules/file-render-plugin.ts | 69 +++++++++++++++++++++++ web_src/js/render/plugins/3d-viewer.ts | 72 ++++++++++++++++++++++++ 9 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 web_src/css/file-view.css create mode 100644 web_src/js/features/file-view.ts create mode 100644 web_src/js/modules/file-render-plugin.ts create mode 100644 web_src/js/render/plugins/3d-viewer.ts diff --git a/package-lock.json b/package-lock.json index 59ce5b33e039c..0f8294880dac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "minimatch": "10.0.2", "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", + "online-3d-viewer": "0.16.0", "pdfobject": "2.3.1", "perfect-debounce": "1.0.0", "postcss": "8.5.5", @@ -2026,6 +2027,16 @@ "vue": "^3.2.29" } }, + "node_modules/@simonwep/pickr": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.9.0.tgz", + "integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==", + "license": "MIT", + "dependencies": { + "core-js": "3.32.2", + "nanopop": "2.3.0" + } + }, "node_modules/@stoplight/better-ajv-errors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", @@ -5337,6 +5348,17 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.32.2", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.32.2.tgz", + "integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.43.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", @@ -7721,6 +7743,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10285,6 +10313,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanopop": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.3.0.tgz", + "integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", @@ -10525,6 +10559,17 @@ "wrappy": "1" } }, + "node_modules/online-3d-viewer": { + "version": "0.16.0", + "resolved": "https://registry.npmmirror.com/online-3d-viewer/-/online-3d-viewer-0.16.0.tgz", + "integrity": "sha512-Mcmo41TM3K+svlMDRH8ySKSY2e8s7Sssdb5U9LV3gkFKVWGGuS304Vk5gqxopAJbE72DpsC67Ve3YNtcAuROwQ==", + "license": "MIT", + "dependencies": { + "@simonwep/pickr": "1.9.0", + "fflate": "0.8.2", + "three": "0.176.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13193,6 +13238,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.176.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.176.0.tgz", + "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", diff --git a/package.json b/package.json index 4faf34900a874..4d1780297824f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "minimatch": "10.0.2", "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", + "online-3d-viewer": "0.16.0", "pdfobject": "2.3.1", "perfect-debounce": "1.0.0", "postcss": "8.5.5", diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index b49818c6b7c63..0e09bdd9e8995 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -110,7 +110,7 @@ {{else if .IsPDFFile}}
{{else}} - {{ctx.Locale.Tr "repo.file_view_raw"}} +
{{end}} {{else if .FileSize}} diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css new file mode 100644 index 0000000000000..811a294c845f3 --- /dev/null +++ b/web_src/css/file-view.css @@ -0,0 +1,71 @@ +/** + * File View & Render Plugin Styles + */ + +/* file view container */ +.file-view-container { + position: relative; + width: 100%; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.file-view-container.is-loading { + position: relative; +} + +.file-view-container.is-loading::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 40px; + height: 40px; + margin-left: -20px; + margin-top: -20px; + border: 5px solid var(--color-secondary); + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.view-raw-fallback { + padding: 16px; + text-align: center; +} + +/* 3D model viewer */ +.model3d-content { + width: 100% !important; + min-height: 400px !important; + border: none !important; + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* error message */ +.file-view-container .ui.error.message { + margin: 1em 0; + width: 100%; +} + +.file-view-container .ui.error.message pre { + margin-top: 0.5em; + font-size: 12px; + max-height: 150px; + overflow: auto; + background-color: rgba(255, 255, 255, 0.1); + padding: 0.5em; +} \ No newline at end of file diff --git a/web_src/css/index.css b/web_src/css/index.css index 291cd04b2b95c..e0d0080d06786 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -85,4 +85,6 @@ @import "./helpers.css"; +@import "./file-view.css"; + @tailwind utilities; diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts new file mode 100644 index 0000000000000..62e204a21d4c0 --- /dev/null +++ b/web_src/js/features/file-view.ts @@ -0,0 +1,59 @@ +import {applyRenderPlugin} from '../modules/file-render-plugin.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; + +/** + * init file view renderer + * + * detect renderable files and apply appropriate plugins + */ +export function initFileView(): void { + // register file view renderer init function + registerGlobalInitFunc('initFileView', async (container: HTMLElement) => { + // get file info + const filename = container.getAttribute('data-filename'); + const fileUrl = container.getAttribute('data-url'); + + // mark loading state + container.classList.add('is-loading'); + + try { + // check if filename and url exist + if (!filename || !fileUrl) { + console.error(`missing filename(${filename}) or file url(${fileUrl}) for rendering`); + throw new Error('missing necessary file info'); + } + + // try to apply render plugin + const success = await applyRenderPlugin(container); + + // if no suitable plugin is found, show default view + if (!success) { + // show default view raw file link + const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + + container.innerHTML = ` +
+ ${fallbackText} +
+ `; + } + } catch (error) { + console.error('file view init error:', error); + + // show error message + const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + + container.innerHTML = ` +
+
Failed to render file
+

Error: ${String(error)}

+
${JSON.stringify({filename, fileUrl}, null, 2)}
+ ${fallbackText} +
+ `; + } finally { + // remove loading state + container.classList.remove('is-loading'); + } + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 7e84773bc18fa..a1da766ccfd54 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -20,6 +20,8 @@ import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; import {initPdfViewer} from './render/pdf.ts'; +import {initFileView} from './features/file-view.ts'; +import {register3DViewerPlugin} from './render/plugins/3d-viewer.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; @@ -163,6 +165,9 @@ onDomReady(() => { initColorPickers, initOAuth2SettingsDisableCheckbox, + + initFileView, + register3DViewerPlugin, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts new file mode 100644 index 0000000000000..4679f64f1b2ef --- /dev/null +++ b/web_src/js/modules/file-render-plugin.ts @@ -0,0 +1,69 @@ +/** + * File Render Plugin System + * + * This module provides a plugin architecture for rendering different file types + * in the browser without requiring backend support for identifying file types. + */ + +/** + * Interface for file render plugins + */ +export type FileRenderPlugin = { + // unique plugin name + name: string; + + // test if plugin can handle specified file + canHandle: (filename: string, mimeType: string) => boolean; + + // render file content + render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; +} + +// store registered render plugins +const plugins: FileRenderPlugin[] = []; + +/** + * register a file render plugin + */ +export function registerFileRenderPlugin(plugin: FileRenderPlugin): void { + plugins.push(plugin); +} + +/** + * find suitable render plugin by filename and mime type + */ +function findPlugin(filename: string, mimeType: string): FileRenderPlugin | null { + return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; +} + +/** + * apply render plugin to specified container + */ +export async function applyRenderPlugin(container: HTMLElement): Promise { + try { + // get file info from container element + const filename = container.getAttribute('data-filename') || ''; + const fileUrl = container.getAttribute('data-url') || ''; + + if (!filename || !fileUrl) { + console.warn('Missing filename or file URL for renderer'); + return false; + } + + // get mime type (optional) + const mimeType = container.getAttribute('data-mime-type') || ''; + + // find plugin that can handle this file + const plugin = findPlugin(filename, mimeType); + if (!plugin) { + return false; + } + + // apply plugin to render file + await plugin.render(container, fileUrl); + return true; + } catch (error) { + console.error('Error applying render plugin:', error); + return false; + } +} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts new file mode 100644 index 0000000000000..a3fe2ede7bb6a --- /dev/null +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -0,0 +1,72 @@ +import type {FileRenderPlugin} from '../../modules/file-render-plugin.ts'; +import {registerFileRenderPlugin} from '../../modules/file-render-plugin.ts'; + +/** + * 3D model file render plugin + * + * support common 3D model file formats, use online-3d-viewer library for rendering + */ +export function register3DViewerPlugin(): void { + // supported 3D file extensions + const SUPPORTED_EXTENSIONS = [ + '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', + '.dae', '.fbx', '.fcstd', '.glb', '.gltf', + '.ifc', '.igs', '.iges', '.stp', '.step', + '.stl', '.obj', '.off', '.ply', '.wrl', + ]; + + // create and register plugin + const plugin: FileRenderPlugin = { + name: '3d-model-viewer', + + // check if file extension is supported 3D file + canHandle(filename: string, _mimeType: string): boolean { + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + const canHandle = SUPPORTED_EXTENSIONS.includes(ext); + return canHandle; + }, + + // render 3D model + async render(container: HTMLElement, fileUrl: string): Promise { + // add loading indicator + container.classList.add('is-loading'); + + try { + // dynamically load 3D rendering library + const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); + + // configure container style + container.classList.add('model3d-content'); + + // initialize 3D viewer + const viewer = new OV.EmbeddedViewer(container, { + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), // transparent + defaultColor: new OV.RGBColor(65, 131, 196), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + + // load model from url + viewer.LoadModelFromUrlList([fileUrl]); + } catch (error) { + // handle render error + console.error('error rendering 3D model:', error); + + // add error message and download button + const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + container.innerHTML = ` +
+
Failed to render 3D model
+

The 3D model could not be displayed in the browser.

+ ${fallbackText} +
+ `; + } finally { + // remove loading state + container.classList.remove('is-loading'); + } + }, + }; + + // register plugin + registerFileRenderPlugin(plugin); +} From 2e80917e2516262e14306298109a10a1262371c0 Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sat, 21 Jun 2025 17:35:24 +0800 Subject: [PATCH 02/19] fix tests error --- tests/integration/lfs_view_test.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go index 64ffebaa787e1..f550bc3bbe2b0 100644 --- a/tests/integration/lfs_view_test.go +++ b/tests/integration/lfs_view_test.go @@ -73,9 +73,14 @@ func TestLFSRender(t *testing.T) { fileInfo := doc.Find("div.file-info-entry").First().Text() assert.Contains(t, fileInfo, "LFS") - rawLink, exists := doc.Find("div.file-view > div.view-raw > a").Attr("href") - assert.True(t, exists, "Download link should render instead of content because this is a binary file") - assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", rawLink, "The download link should use the proper /media link because it's in LFS") + // find new file view container + fileViewContainer := doc.Find("div.file-view-container") + assert.Positive(t, fileViewContainer.Length(), "File view container should exist") + + // check data attribute instead of link href + dataURL, exists := fileViewContainer.Attr("data-url") + assert.True(t, exists, "File view container should have data-url attribute") + assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", dataURL, "The data-url should use the proper /media link because it's in LFS") }) // check that a directory with a README file shows its text From c84be598202b3c41f6f0d76062d6229f09923c64 Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Fri, 27 Jun 2025 23:39:35 +0800 Subject: [PATCH 03/19] fix --- templates/repo/view_file.tmpl | 2 +- web_src/css/file-view.css | 19 ------------------- web_src/js/features/file-view.ts | 22 +++++++++++----------- web_src/js/index.ts | 2 +- web_src/js/modules/file-render-plugin.ts | 12 ++++++------ web_src/js/render/plugins/3d-viewer.ts | 11 +---------- 6 files changed, 20 insertions(+), 48 deletions(-) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 0e09bdd9e8995..3a6c886e52b87 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -110,7 +110,7 @@ {{else if .IsPDFFile}}
{{else}} -
+
{{end}} {{else if .FileSize}} diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css index 811a294c845f3..1d4cf22ab7bd8 100644 --- a/web_src/css/file-view.css +++ b/web_src/css/file-view.css @@ -12,25 +12,6 @@ justify-content: center; } -.file-view-container.is-loading { - position: relative; -} - -.file-view-container.is-loading::after { - content: ""; - position: absolute; - left: 50%; - top: 50%; - width: 40px; - height: 40px; - margin-left: -20px; - margin-top: -20px; - border: 5px solid var(--color-secondary); - border-top-color: transparent; - border-radius: 50%; - animation: spin 1s linear infinite; -} - .view-raw-fallback { padding: 16px; text-align: center; diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 62e204a21d4c0..13a2abe6d9370 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -10,16 +10,16 @@ export function initFileView(): void { // register file view renderer init function registerGlobalInitFunc('initFileView', async (container: HTMLElement) => { // get file info - const filename = container.getAttribute('data-filename'); - const fileUrl = container.getAttribute('data-url'); + const treePath = container.getAttribute('data-tree-path'); + const fileLink = container.getAttribute('data-raw-file-link'); // mark loading state container.classList.add('is-loading'); try { // check if filename and url exist - if (!filename || !fileUrl) { - console.error(`missing filename(${filename}) or file url(${fileUrl}) for rendering`); + if (!treePath || !fileLink) { + console.error(`missing file name(${treePath}) or file url(${fileLink}) for rendering`); throw new Error('missing necessary file info'); } @@ -29,11 +29,11 @@ export function initFileView(): void { // if no suitable plugin is found, show default view if (!success) { // show default view raw file link - const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + const fallbackText = container.getAttribute('data-fallback-text'); container.innerHTML = ` `; } @@ -41,14 +41,14 @@ export function initFileView(): void { console.error('file view init error:', error); // show error message - const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + const fallbackText = container.getAttribute('data-fallback-text'); + const errorHeader = container.getAttribute('data-error-header'); container.innerHTML = `
-
Failed to render file
-

Error: ${String(error)}

-
${JSON.stringify({filename, fileUrl}, null, 2)}
- ${fallbackText} +
${errorHeader}
+
${JSON.stringify({treePath, fileLink}, null, 2)}
+ ${fallbackText}
`; } finally { diff --git a/web_src/js/index.ts b/web_src/js/index.ts index a1da766ccfd54..6ad9c4724b1d3 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -166,8 +166,8 @@ onDomReady(() => { initOAuth2SettingsDisableCheckbox, - initFileView, register3DViewerPlugin, + initFileView, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts index 4679f64f1b2ef..898a71f5f6070 100644 --- a/web_src/js/modules/file-render-plugin.ts +++ b/web_src/js/modules/file-render-plugin.ts @@ -42,11 +42,11 @@ function findPlugin(filename: string, mimeType: string): FileRenderPlugin | null export async function applyRenderPlugin(container: HTMLElement): Promise { try { // get file info from container element - const filename = container.getAttribute('data-filename') || ''; - const fileUrl = container.getAttribute('data-url') || ''; + const treePath = container.getAttribute('data-tree-path') || ''; + const fileLink = container.getAttribute('data-raw-file-link') || ''; - if (!filename || !fileUrl) { - console.warn('Missing filename or file URL for renderer'); + if (!treePath || !fileLink) { + console.warn('Missing file name or file URL for renderer'); return false; } @@ -54,13 +54,13 @@ export async function applyRenderPlugin(container: HTMLElement): Promise -
Failed to render 3D model
-

The 3D model could not be displayed in the browser.

- ${fallbackText} - - `; + throw error; } finally { // remove loading state container.classList.remove('is-loading'); From 3a6dd02955d42dcf604c9e01fd602eb230a2fb2d Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sat, 28 Jun 2025 00:18:09 +0800 Subject: [PATCH 04/19] refactor the PdfViewer --- templates/repo/view_file.tmpl | 2 +- web_src/css/file-view.css | 22 +++++++++++++++ web_src/css/modules/animations.css | 3 +- web_src/css/repo.css | 17 ------------ web_src/js/features/file-view.ts | 37 +++++++++++++++---------- web_src/js/index.ts | 4 +-- web_src/js/render/pdf.ts | 17 ------------ web_src/js/render/plugins/3d-viewer.ts | 16 +---------- web_src/js/render/plugins/pdf-viewer.ts | 25 +++++++++++++++++ 9 files changed, 75 insertions(+), 68 deletions(-) delete mode 100644 web_src/js/render/pdf.ts create mode 100644 web_src/js/render/plugins/pdf-viewer.ts diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 3a6c886e52b87..cd91ec5de446b 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -108,7 +108,7 @@ {{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}} {{else if .IsPDFFile}} -
+
{{else}}
{{end}} diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css index 1d4cf22ab7bd8..67966dd419536 100644 --- a/web_src/css/file-view.css +++ b/web_src/css/file-view.css @@ -27,6 +27,28 @@ justify-content: center; } +/* PDF viewer */ +.pdf-view-content { + width: 100%; + height: 600px; + border: none !important; + display: flex; + align-items: center; + justify-content: center; +} + +.pdf-view-content .pdf-fallback-button { + margin: 50px auto; +} + +.file-view-container .pdfobject { + border-radius: 0 0 var(--border-radius) var(--border-radius); +} + +.pdf-view-content.is-loading { + height: var(--height-loading); +} + @keyframes spin { 0% { transform: rotate(0deg); diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index 8edf31ddbd16d..deaaf83680bcb 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -52,8 +52,7 @@ form.single-button-form.is-loading .button { } .markup pre.is-loading, -.editor-loading.is-loading, -.pdf-content.is-loading { +.editor-loading.is-loading { height: var(--height-loading); } diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 1a05b68dd4ec2..c93b10f5ca6d2 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -195,19 +195,6 @@ td .commit-summary { max-width: 600px !important; } -.pdf-content { - width: 100%; - height: 600px; - border: none !important; - display: flex; - align-items: center; - justify-content: center; -} - -.pdf-content .pdf-fallback-button { - margin: 50px auto; -} - .repository.file.list .non-diff-file-content .plain-text { padding: 1em 2em; } @@ -230,10 +217,6 @@ td .commit-summary { padding: 0 !important; } -.non-diff-file-content .pdfobject { - border-radius: 0 0 var(--border-radius) var(--border-radius); -} - .repo-editor-header { width: 100%; } diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 13a2abe6d9370..65fe90f73cb73 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,5 +1,6 @@ import {applyRenderPlugin} from '../modules/file-render-plugin.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; +import {createElementFromAttrs} from '../utils/dom.ts'; /** * init file view renderer @@ -30,12 +31,16 @@ export function initFileView(): void { if (!success) { // show default view raw file link const fallbackText = container.getAttribute('data-fallback-text'); - - container.innerHTML = ` - - `; + const wrapper = createElementFromAttrs( + 'div', + {class: 'view-raw-fallback'}, + createElementFromAttrs('a', { + class: 'ui basic button', + target: '_blank', + href: fileLink, + }, fallbackText || ''), + ); + container.replaceChildren(wrapper); } } catch (error) { console.error('file view init error:', error); @@ -43,14 +48,18 @@ export function initFileView(): void { // show error message const fallbackText = container.getAttribute('data-fallback-text'); const errorHeader = container.getAttribute('data-error-header'); - - container.innerHTML = ` -
-
${errorHeader}
-
${JSON.stringify({treePath, fileLink}, null, 2)}
- ${fallbackText} -
- `; + const errorDiv = createElementFromAttrs( + 'div', + {class: 'ui error message'}, + createElementFromAttrs('div', {class: 'header'}, errorHeader || ''), + createElementFromAttrs('pre', null, JSON.stringify({treePath, fileLink}, null, 2)), + createElementFromAttrs('a', { + class: 'ui basic button', + href: fileLink || '#', + target: '_blank', + }, fallbackText || ''), + ); + container.replaceChildren(errorDiv); } finally { // remove loading state container.classList.remove('is-loading'); diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 6ad9c4724b1d3..c6ac37ba7caf1 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -19,7 +19,6 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; -import {initPdfViewer} from './render/pdf.ts'; import {initFileView} from './features/file-view.ts'; import {register3DViewerPlugin} from './render/plugins/3d-viewer.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; @@ -67,6 +66,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; import {callInitFunctions} from './modules/init.ts'; import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; +import {registerPdfViewerPlugin} from './render/plugins/pdf-viewer.ts'; initGiteaFomantic(); initSubmitEventPolyfill(); @@ -161,12 +161,12 @@ onDomReady(() => { initUserAuthWebAuthnRegister, initUserSettings, initRepoDiffView, - initPdfViewer, initColorPickers, initOAuth2SettingsDisableCheckbox, register3DViewerPlugin, + registerPdfViewerPlugin, initFileView, ]); diff --git a/web_src/js/render/pdf.ts b/web_src/js/render/pdf.ts deleted file mode 100644 index 283b4ed85c933..0000000000000 --- a/web_src/js/render/pdf.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {htmlEscape} from 'escape-goat'; -import {registerGlobalInitFunc} from '../modules/observer.ts'; - -export async function initPdfViewer() { - registerGlobalInitFunc('initPdfViewer', async (el: HTMLInputElement) => { - const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); - - const src = el.getAttribute('data-src'); - const fallbackText = el.getAttribute('data-fallback-button-text'); - pdfobject.embed(src, el, { - fallbackLink: htmlEscape` - ${fallbackText} - `, - }); - el.classList.remove('is-loading'); - }); -} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index bb1f94ec6039d..4cf18b88ddd32 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -28,32 +28,18 @@ export function register3DViewerPlugin(): void { // render 3D model async render(container: HTMLElement, fileUrl: string): Promise { - // add loading indicator - container.classList.add('is-loading'); - try { - // dynamically load 3D rendering library const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); - - // configure container style container.classList.add('model3d-content'); - - // initialize 3D viewer const viewer = new OV.EmbeddedViewer(container, { - backgroundColor: new OV.RGBAColor(59, 68, 76, 0), // transparent + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), defaultColor: new OV.RGBColor(65, 131, 196), edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), }); - - // load model from url viewer.LoadModelFromUrlList([fileUrl]); } catch (error) { - // handle render error console.error('error rendering 3D model:', error); throw error; - } finally { - // remove loading state - container.classList.remove('is-loading'); } }, }; diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts new file mode 100644 index 0000000000000..1bcb413992f0f --- /dev/null +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -0,0 +1,25 @@ +import type {FileRenderPlugin} from '../../modules/file-render-plugin.ts'; +import {registerFileRenderPlugin} from '../../modules/file-render-plugin.ts'; + +export function registerPdfViewerPlugin(): void { + const plugin: FileRenderPlugin = { + name: 'pdf-viewer', + canHandle(filename: string, _mimeType: string): boolean { + return filename.toLowerCase().endsWith('.pdf'); + }, + async render(container: HTMLElement, fileUrl: string): Promise { + try { + const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); + container.classList.add('pdf-view-content'); + const fallbackText = container.getAttribute('data-fallback-text'); + PDFObject.default.embed(fileUrl, container, { + fallbackLink: `${fallbackText}`, + }); + } catch (error) { + console.error('error rendering PDF:', error); + throw error; + } + }, + }; + registerFileRenderPlugin(plugin); +} From 505b644ff7be46b1adb13211c6e9270f48193a83 Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sat, 28 Jun 2025 01:17:25 +0800 Subject: [PATCH 05/19] fix test error --- tests/integration/lfs_view_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go index f550bc3bbe2b0..88c65ff3b437e 100644 --- a/tests/integration/lfs_view_test.go +++ b/tests/integration/lfs_view_test.go @@ -78,9 +78,9 @@ func TestLFSRender(t *testing.T) { assert.Positive(t, fileViewContainer.Length(), "File view container should exist") // check data attribute instead of link href - dataURL, exists := fileViewContainer.Attr("data-url") - assert.True(t, exists, "File view container should have data-url attribute") - assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", dataURL, "The data-url should use the proper /media link because it's in LFS") + dataURL, exists := fileViewContainer.Attr("data-raw-file-link") + assert.True(t, exists, "File view container should have data-raw-file-link attribute") + assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", dataURL, "The data-raw-file-link should use the proper /media link because it's in LFS") }) // check that a directory with a README file shows its text From 00eb205ae40fa1c4ed47751de5a0f2d3e66a3968 Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sat, 28 Jun 2025 01:42:35 +0800 Subject: [PATCH 06/19] fix --- templates/repo/view_file.tmpl | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index cd91ec5de446b..0a70551984bb5 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -107,8 +107,6 @@ - {{else if .IsPDFFile}} -
{{else}}
{{end}} From 2e7fe6e8932eaf72241280b0d0b162f40413297f Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sat, 28 Jun 2025 15:43:35 +0800 Subject: [PATCH 07/19] clean code --- web_src/css/file-view.css | 9 --------- 1 file changed, 9 deletions(-) diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css index 67966dd419536..ac1a1212fd71e 100644 --- a/web_src/css/file-view.css +++ b/web_src/css/file-view.css @@ -49,15 +49,6 @@ height: var(--height-loading); } -@keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - /* error message */ .file-view-container .ui.error.message { margin: 1em 0; From f54fecf87df9b8050f6867300a03c634520b6dce Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Jun 2025 23:49:38 +0800 Subject: [PATCH 08/19] temp --- routers/web/repo/setting/lfs.go | 2 - routers/web/repo/view_file.go | 2 - templates/repo/settings/lfs_file.tmpl | 4 +- templates/repo/view_file.tmpl | 2 +- templates/shared/repo/fileviewrender.tmpl | 6 ++ web_src/js/features/file-view.ts | 86 ++++++++--------------- web_src/js/index.ts | 8 +-- web_src/js/modules/file-render-plugin.ts | 35 +++------ 8 files changed, 48 insertions(+), 97 deletions(-) create mode 100644 templates/shared/repo/fileviewrender.tmpl diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index bbbb99dc89a90..f00ab1a8c5abf 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -309,8 +309,6 @@ func LFSFileGet(ctx *context.Context) { } ctx.Data["LineNums"] = gotemplate.HTML(output.String()) - case st.IsPDF(): - ctx.Data["IsPDFFile"] = true case st.IsVideo(): ctx.Data["IsVideoFile"] = true case st.IsAudio(): diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 5606a8e6ecdc4..d2273f4645153 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -237,8 +237,6 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["LineEscapeStatus"] = statuses } - case fInfo.st.IsPDF(): - ctx.Data["IsPDFFile"] = true case fInfo.st.IsVideo(): ctx.Data["IsVideoFile"] = true case fInfo.st.IsAudio(): diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl index 1a8014e21877d..640ebf022f04a 100644 --- a/templates/repo/settings/lfs_file.tmpl +++ b/templates/repo/settings/lfs_file.tmpl @@ -30,10 +30,8 @@ - {{else if .IsPDFFile}} -
{{else}} - {{ctx.Locale.Tr "repo.file_view_raw"}} + {{template "shared/repo/fileviewrender" dict "RawFileLink" $.RawFileLink}} {{end}} {{else if .FileSize}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 0a70551984bb5..f27b856674cae 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -108,7 +108,7 @@ {{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}} {{else}} -
+ {{template "shared/repo/fileviewrender" dict "RawFileLink" $.RawFileLink}} {{end}} {{else if .FileSize}} diff --git a/templates/shared/repo/fileviewrender.tmpl b/templates/shared/repo/fileviewrender.tmpl new file mode 100644 index 0000000000000..a6865afe035e4 --- /dev/null +++ b/templates/shared/repo/fileviewrender.tmpl @@ -0,0 +1,6 @@ + diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 65fe90f73cb73..0b250528a3905 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,68 +1,42 @@ import {applyRenderPlugin} from '../modules/file-render-plugin.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; -import {createElementFromAttrs} from '../utils/dom.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; +import {register3DViewerPlugin} from '../render/plugins/3d-viewer.ts'; +import {registerPdfViewerPlugin} from '../render/plugins/pdf-viewer.ts'; +import {htmlEscape} from 'escape-goat'; -/** - * init file view renderer - * - * detect renderable files and apply appropriate plugins - */ -export function initFileView(): void { - // register file view renderer init function - registerGlobalInitFunc('initFileView', async (container: HTMLElement) => { - // get file info - const treePath = container.getAttribute('data-tree-path'); - const fileLink = container.getAttribute('data-raw-file-link'); +export function initFileViewRender(): void { + let pluginRegistered = false; - // mark loading state - container.classList.add('is-loading'); + registerGlobalInitFunc('initFileViewRender', async (container: HTMLElement) => { + if (!pluginRegistered) { + pluginRegistered = true; + register3DViewerPlugin(); + registerPdfViewerPlugin(); + } + + const rawFileLink = container.getAttribute('data-raw-file-link'); + const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); + if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); + let rendered = false, errorMsg = ''; try { - // check if filename and url exist - if (!treePath || !fileLink) { - console.error(`missing file name(${treePath}) or file url(${fileLink}) for rendering`); - throw new Error('missing necessary file info'); - } + rendered = await applyRenderPlugin(container); + } catch (e) { + errorMsg = `${e}`; + } - // try to apply render plugin - const success = await applyRenderPlugin(container); + if (rendered) { + elViewRawPrompt.remove(); + return; + } - // if no suitable plugin is found, show default view - if (!success) { - // show default view raw file link - const fallbackText = container.getAttribute('data-fallback-text'); - const wrapper = createElementFromAttrs( - 'div', - {class: 'view-raw-fallback'}, - createElementFromAttrs('a', { - class: 'ui basic button', - target: '_blank', - href: fileLink, - }, fallbackText || ''), - ); - container.replaceChildren(wrapper); - } - } catch (error) { - console.error('file view init error:', error); + // remove all children from the container, and only show the raw file link + container.replaceChildren(elViewRawPrompt); - // show error message - const fallbackText = container.getAttribute('data-fallback-text'); - const errorHeader = container.getAttribute('data-error-header'); - const errorDiv = createElementFromAttrs( - 'div', - {class: 'ui error message'}, - createElementFromAttrs('div', {class: 'header'}, errorHeader || ''), - createElementFromAttrs('pre', null, JSON.stringify({treePath, fileLink}, null, 2)), - createElementFromAttrs('a', { - class: 'ui basic button', - href: fileLink || '#', - target: '_blank', - }, fallbackText || ''), - ); - container.replaceChildren(errorDiv); - } finally { - // remove loading state - container.classList.remove('is-loading'); + if (errorMsg) { + const elErrorMessage = createElementFromHTML(htmlEscape`
${errorMsg}
`); + container.insertBefore(elErrorMessage, elViewRawPrompt); } }); } diff --git a/web_src/js/index.ts b/web_src/js/index.ts index c6ac37ba7caf1..eb51c52088a15 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -19,8 +19,7 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; -import {initFileView} from './features/file-view.ts'; -import {register3DViewerPlugin} from './render/plugins/3d-viewer.ts'; +import {initFileViewRender} from './features/file-view.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; @@ -66,7 +65,6 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; import {callInitFunctions} from './modules/init.ts'; import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; -import {registerPdfViewerPlugin} from './render/plugins/pdf-viewer.ts'; initGiteaFomantic(); initSubmitEventPolyfill(); @@ -165,9 +163,7 @@ onDomReady(() => { initOAuth2SettingsDisableCheckbox, - register3DViewerPlugin, - registerPdfViewerPlugin, - initFileView, + initFileViewRender, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts index 898a71f5f6070..77e5683d87d34 100644 --- a/web_src/js/modules/file-render-plugin.ts +++ b/web_src/js/modules/file-render-plugin.ts @@ -4,15 +4,11 @@ * This module provides a plugin architecture for rendering different file types * in the browser without requiring backend support for identifying file types. */ - -/** - * Interface for file render plugins - */ export type FileRenderPlugin = { // unique plugin name name: string; - // test if plugin can handle specified file + // test if plugin can handle a specified file canHandle: (filename: string, mimeType: string) => boolean; // render file content @@ -39,31 +35,16 @@ function findPlugin(filename: string, mimeType: string): FileRenderPlugin | null /** * apply render plugin to specified container */ -export async function applyRenderPlugin(container: HTMLElement): Promise { +export async function applyRenderPlugin(container: HTMLElement, rawFileLink: string): Promise { try { - // get file info from container element - const treePath = container.getAttribute('data-tree-path') || ''; - const fileLink = container.getAttribute('data-raw-file-link') || ''; - - if (!treePath || !fileLink) { - console.warn('Missing file name or file URL for renderer'); - return false; - } - - // get mime type (optional) const mimeType = container.getAttribute('data-mime-type') || ''; + const plugin = findPlugin(rawFileLink, mimeType); + if (!plugin) return false; - // find plugin that can handle this file - const plugin = findPlugin(treePath, mimeType); - if (!plugin) { - return false; - } - - // apply plugin to render file - await plugin.render(container, fileLink); + container.classList.add('is-loading'); + await plugin.render(container, rawFileLink); return true; - } catch (error) { - console.error('Error applying render plugin:', error); - return false; + } finally { + container.classList.remove('is-loading'); } } From 2f4526849bcd2db017631e797320cd3d59a1da49 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 00:12:26 +0800 Subject: [PATCH 09/19] fix --- templates/shared/repo/fileviewrender.tmpl | 4 +- web_src/css/file-view.css | 66 +---------------------- web_src/css/repo.css | 6 --- web_src/js/features/file-view.ts | 4 +- web_src/js/modules/file-render-plugin.ts | 1 + web_src/js/render/plugins/3d-viewer.ts | 27 ++++------ web_src/js/render/plugins/pdf-viewer.ts | 15 ++---- 7 files changed, 23 insertions(+), 100 deletions(-) diff --git a/templates/shared/repo/fileviewrender.tmpl b/templates/shared/repo/fileviewrender.tmpl index a6865afe035e4..fcc5919980df6 100644 --- a/templates/shared/repo/fileviewrender.tmpl +++ b/templates/shared/repo/fileviewrender.tmpl @@ -2,5 +2,7 @@ data-raw-file-link="{{$.RawFileLink}}" data-fallback-text="{{ctx.Locale.Tr "repo.file_view_raw"}}" data-error-header="{{ctx.Locale.Tr "repo.file_render_failed"}}"> - {{ctx.Locale.Tr "repo.file_view_raw"}} + diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css index ac1a1212fd71e..30d01210d0cc6 100644 --- a/web_src/css/file-view.css +++ b/web_src/css/file-view.css @@ -1,65 +1,3 @@ -/** - * File View & Render Plugin Styles - */ - -/* file view container */ -.file-view-container { - position: relative; - width: 100%; - min-height: 200px; - display: flex; - align-items: center; - justify-content: center; +/* TODO: add more styles? */ +.file-view-render-container { } - -.view-raw-fallback { - padding: 16px; - text-align: center; -} - -/* 3D model viewer */ -.model3d-content { - width: 100% !important; - min-height: 400px !important; - border: none !important; - display: flex; - align-items: center; - justify-content: center; -} - -/* PDF viewer */ -.pdf-view-content { - width: 100%; - height: 600px; - border: none !important; - display: flex; - align-items: center; - justify-content: center; -} - -.pdf-view-content .pdf-fallback-button { - margin: 50px auto; -} - -.file-view-container .pdfobject { - border-radius: 0 0 var(--border-radius) var(--border-radius); -} - -.pdf-view-content.is-loading { - height: var(--height-loading); -} - -/* error message */ -.file-view-container .ui.error.message { - margin: 1em 0; - width: 100%; -} - -.file-view-container .ui.error.message pre { - margin-top: 0.5em; - font-size: 12px; - max-height: 150px; - overflow: auto; - background-color: rgba(255, 255, 255, 0.1); - padding: 0.5em; -} \ No newline at end of file diff --git a/web_src/css/repo.css b/web_src/css/repo.css index cd84ad1e2ad1a..abdeabcea341d 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -183,12 +183,6 @@ td .commit-summary { cursor: default; } -.view-raw { - display: flex; - justify-content: center; - align-items: center; -} - .view-raw > * { max-width: 100%; } diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 0b250528a3905..7dd8ea12ab63d 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -21,7 +21,7 @@ export function initFileViewRender(): void { let rendered = false, errorMsg = ''; try { - rendered = await applyRenderPlugin(container); + rendered = await applyRenderPlugin(container, rawFileLink); } catch (e) { errorMsg = `${e}`; } @@ -36,7 +36,7 @@ export function initFileViewRender(): void { if (errorMsg) { const elErrorMessage = createElementFromHTML(htmlEscape`
${errorMsg}
`); - container.insertBefore(elErrorMessage, elViewRawPrompt); + elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage); } }); } diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts index 77e5683d87d34..fdc536680bf7e 100644 --- a/web_src/js/modules/file-render-plugin.ts +++ b/web_src/js/modules/file-render-plugin.ts @@ -42,6 +42,7 @@ export async function applyRenderPlugin(container: HTMLElement, rawFileLink: str if (!plugin) return false; container.classList.add('is-loading'); + container.setAttribute('data-render-name', plugin.name); await plugin.render(container, rawFileLink); return true; } finally { diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index 4cf18b88ddd32..9c0c653b15121 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -19,31 +19,24 @@ export function register3DViewerPlugin(): void { const plugin: FileRenderPlugin = { name: '3d-model-viewer', - // check if file extension is supported 3D file + // check if file extension is a supported 3D file canHandle(filename: string, _mimeType: string): boolean { const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); - const canHandle = SUPPORTED_EXTENSIONS.includes(ext); - return canHandle; + return SUPPORTED_EXTENSIONS.includes(ext); }, // render 3D model async render(container: HTMLElement, fileUrl: string): Promise { - try { - const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); - container.classList.add('model3d-content'); - const viewer = new OV.EmbeddedViewer(container, { - backgroundColor: new OV.RGBAColor(59, 68, 76, 0), - defaultColor: new OV.RGBColor(65, 131, 196), - edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), - }); - viewer.LoadModelFromUrlList([fileUrl]); - } catch (error) { - console.error('error rendering 3D model:', error); - throw error; - } + const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); + container.classList.add('model3d-content'); + const viewer = new OV.EmbeddedViewer(container, { + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), + defaultColor: new OV.RGBColor(65, 131, 196), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + viewer.LoadModelFromUrlList([fileUrl]); }, }; - // register plugin registerFileRenderPlugin(plugin); } diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts index 1bcb413992f0f..741dd4d91711d 100644 --- a/web_src/js/render/plugins/pdf-viewer.ts +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -8,16 +8,11 @@ export function registerPdfViewerPlugin(): void { return filename.toLowerCase().endsWith('.pdf'); }, async render(container: HTMLElement, fileUrl: string): Promise { - try { - const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); - container.classList.add('pdf-view-content'); - const fallbackText = container.getAttribute('data-fallback-text'); - PDFObject.default.embed(fileUrl, container, { - fallbackLink: `${fallbackText}`, - }); - } catch (error) { - console.error('error rendering PDF:', error); - throw error; + const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); + // TODO: the PDFObject library does not support dynamic height adjustment, + container.style.height = `${window.innerHeight - 100}px`; + if (!PDFObject.default.embed(fileUrl, container)) { + throw new Error('Unable to render the PDF file'); } }, }; From 24d7bab16afd5a341fdc7e79c0bbcac9b1eaa1a1 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 00:20:37 +0800 Subject: [PATCH 10/19] fix --- templates/shared/repo/fileviewrender.tmpl | 5 +---- web_src/css/file-view.css | 4 ++-- web_src/js/features/file-view.ts | 14 ++++++++++-- web_src/js/modules/file-render-plugin.ts | 27 +---------------------- 4 files changed, 16 insertions(+), 34 deletions(-) diff --git a/templates/shared/repo/fileviewrender.tmpl b/templates/shared/repo/fileviewrender.tmpl index fcc5919980df6..cb739bcfff289 100644 --- a/templates/shared/repo/fileviewrender.tmpl +++ b/templates/shared/repo/fileviewrender.tmpl @@ -1,7 +1,4 @@ -
+
diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css index 30d01210d0cc6..dec919cfe19b1 100644 --- a/web_src/css/file-view.css +++ b/web_src/css/file-view.css @@ -1,3 +1,3 @@ -/* TODO: add more styles? */ -.file-view-render-container { +.file-view-render-container :last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); /* to match the "ui segment" bottom radius */ } diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 7dd8ea12ab63d..f1eeef4b07292 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,9 +1,10 @@ -import {applyRenderPlugin} from '../modules/file-render-plugin.ts'; +import {findFileRenderPlugin} from '../modules/file-render-plugin.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {createElementFromHTML} from '../utils/dom.ts'; import {register3DViewerPlugin} from '../render/plugins/3d-viewer.ts'; import {registerPdfViewerPlugin} from '../render/plugins/pdf-viewer.ts'; import {htmlEscape} from 'escape-goat'; +import {basename} from '../utils.ts'; export function initFileViewRender(): void { let pluginRegistered = false; @@ -16,14 +17,23 @@ export function initFileViewRender(): void { } const rawFileLink = container.getAttribute('data-raw-file-link'); + const mimeType = container.getAttribute('data-mime-type') || ''; // not used yet const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); let rendered = false, errorMsg = ''; try { - rendered = await applyRenderPlugin(container, rawFileLink); + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (plugin) { + container.classList.add('is-loading'); + container.setAttribute('data-render-name', plugin.name); // not used yet + await plugin.render(container, rawFileLink); + rendered = true; + } } catch (e) { errorMsg = `${e}`; + } finally { + container.classList.remove('is-loading'); } if (rendered) { diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts index fdc536680bf7e..5f57e533815e1 100644 --- a/web_src/js/modules/file-render-plugin.ts +++ b/web_src/js/modules/file-render-plugin.ts @@ -15,37 +15,12 @@ export type FileRenderPlugin = { render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; } -// store registered render plugins const plugins: FileRenderPlugin[] = []; -/** - * register a file render plugin - */ export function registerFileRenderPlugin(plugin: FileRenderPlugin): void { plugins.push(plugin); } -/** - * find suitable render plugin by filename and mime type - */ -function findPlugin(filename: string, mimeType: string): FileRenderPlugin | null { +export function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; } - -/** - * apply render plugin to specified container - */ -export async function applyRenderPlugin(container: HTMLElement, rawFileLink: string): Promise { - try { - const mimeType = container.getAttribute('data-mime-type') || ''; - const plugin = findPlugin(rawFileLink, mimeType); - if (!plugin) return false; - - container.classList.add('is-loading'); - container.setAttribute('data-render-name', plugin.name); - await plugin.render(container, rawFileLink); - return true; - } finally { - container.classList.remove('is-loading'); - } -} From 9692379fe18a9dbd15cd659850e9b0d3fb62f7c6 Mon Sep 17 00:00:00 2001 From: kerwin612 Date: Sun, 29 Jun 2025 09:03:57 +0800 Subject: [PATCH 11/19] fix test error --- tests/integration/lfs_view_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go index 88c65ff3b437e..9d8c9af2a122c 100644 --- a/tests/integration/lfs_view_test.go +++ b/tests/integration/lfs_view_test.go @@ -74,7 +74,7 @@ func TestLFSRender(t *testing.T) { assert.Contains(t, fileInfo, "LFS") // find new file view container - fileViewContainer := doc.Find("div.file-view-container") + fileViewContainer := doc.Find("div.file-view-render-container") assert.Positive(t, fileViewContainer.Length(), "File view container should exist") // check data attribute instead of link href From 9943219782b620ada7c12d519599b33ee9b5dccf Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 09:42:11 +0800 Subject: [PATCH 12/19] fix css --- web_src/css/file-view.css | 3 --- web_src/css/index.css | 2 -- web_src/css/repo.css | 17 ----------------- web_src/css/repo/file-view.css | 30 ++++++++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 22 deletions(-) delete mode 100644 web_src/css/file-view.css diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css deleted file mode 100644 index dec919cfe19b1..0000000000000 --- a/web_src/css/file-view.css +++ /dev/null @@ -1,3 +0,0 @@ -.file-view-render-container :last-child { - border-radius: 0 0 var(--border-radius) var(--border-radius); /* to match the "ui segment" bottom radius */ -} diff --git a/web_src/css/index.css b/web_src/css/index.css index e0d0080d06786..291cd04b2b95c 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -85,6 +85,4 @@ @import "./helpers.css"; -@import "./file-view.css"; - @tailwind utilities; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index abdeabcea341d..a72709c382020 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -183,23 +183,6 @@ td .commit-summary { cursor: default; } -.view-raw > * { - max-width: 100%; -} - -.view-raw audio, -.view-raw video, -.view-raw img { - margin: 1rem 0; - border-radius: 0; - object-fit: contain; -} - -.view-raw img[src$=".svg" i] { - max-height: 600px !important; - max-width: 600px !important; -} - .repository.file.list .non-diff-file-content .plain-text { padding: 1em 2em; } diff --git a/web_src/css/repo/file-view.css b/web_src/css/repo/file-view.css index 54af5f4602459..907f136afea2b 100644 --- a/web_src/css/repo/file-view.css +++ b/web_src/css/repo/file-view.css @@ -60,3 +60,33 @@ .file-view.code-view .ui.button.code-line-button:hover { background: var(--color-secondary); } + +.view-raw { + display: flex; + justify-content: center; +} + +.view-raw > * { + max-width: 100%; +} + +.view-raw audio, +.view-raw video, +.view-raw img { + margin: 1rem; + border-radius: 0; + object-fit: contain; +} + +.view-raw img[src$=".svg" i] { + max-height: 600px !important; + max-width: 600px !important; +} + +.file-view-render-container { + width: 100%; +} + +.file-view-render-container :last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); /* to match the "ui segment" bottom radius */ +} From d217dd98bda96b632e46aa537005e7adb7d28ac2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 10:08:13 +0800 Subject: [PATCH 13/19] refactor --- web_src/js/features/file-view.ts | 25 ++++++++++++-------- web_src/js/modules/file-render-plugin.ts | 26 -------------------- web_src/js/render/plugin.ts | 10 ++++++++ web_src/js/render/plugins/3d-viewer.ts | 30 +++++++++++------------- web_src/js/render/plugins/pdf-viewer.ts | 10 ++++---- 5 files changed, 44 insertions(+), 57 deletions(-) delete mode 100644 web_src/js/modules/file-render-plugin.ts create mode 100644 web_src/js/render/plugin.ts diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index f1eeef4b07292..41a422163b033 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,20 +1,25 @@ -import {findFileRenderPlugin} from '../modules/file-render-plugin.ts'; +import type {FileRenderPlugin} from '../render/plugin.ts'; +import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; +import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {createElementFromHTML} from '../utils/dom.ts'; -import {register3DViewerPlugin} from '../render/plugins/3d-viewer.ts'; -import {registerPdfViewerPlugin} from '../render/plugins/pdf-viewer.ts'; import {htmlEscape} from 'escape-goat'; import {basename} from '../utils.ts'; -export function initFileViewRender(): void { - let pluginRegistered = false; +const plugins: FileRenderPlugin[] = []; + +function initPluginsOnce(): void { + if (plugins.length) return; + plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); +} +function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { + return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; +} + +export function initFileViewRender(): void { registerGlobalInitFunc('initFileViewRender', async (container: HTMLElement) => { - if (!pluginRegistered) { - pluginRegistered = true; - register3DViewerPlugin(); - registerPdfViewerPlugin(); - } + initPluginsOnce(); const rawFileLink = container.getAttribute('data-raw-file-link'); const mimeType = container.getAttribute('data-mime-type') || ''; // not used yet diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts deleted file mode 100644 index 5f57e533815e1..0000000000000 --- a/web_src/js/modules/file-render-plugin.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * File Render Plugin System - * - * This module provides a plugin architecture for rendering different file types - * in the browser without requiring backend support for identifying file types. - */ -export type FileRenderPlugin = { - // unique plugin name - name: string; - - // test if plugin can handle a specified file - canHandle: (filename: string, mimeType: string) => boolean; - - // render file content - render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; -} - -const plugins: FileRenderPlugin[] = []; - -export function registerFileRenderPlugin(plugin: FileRenderPlugin): void { - plugins.push(plugin); -} - -export function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { - return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; -} diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts new file mode 100644 index 0000000000000..a8dd0a7c0542f --- /dev/null +++ b/web_src/js/render/plugin.ts @@ -0,0 +1,10 @@ +export type FileRenderPlugin = { + // unique plugin name + name: string; + + // test if plugin can handle a specified file + canHandle: (filename: string, mimeType: string) => boolean; + + // render file content + render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; +} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index 9c0c653b15121..51687cec11f76 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -1,13 +1,16 @@ -import type {FileRenderPlugin} from '../../modules/file-render-plugin.ts'; -import {registerFileRenderPlugin} from '../../modules/file-render-plugin.ts'; +import type {FileRenderPlugin} from '../plugin.ts'; +import {extname} from '../../utils.ts'; -/** - * 3D model file render plugin - * - * support common 3D model file formats, use online-3d-viewer library for rendering - */ -export function register3DViewerPlugin(): void { - // supported 3D file extensions +// support common 3D model file formats, use online-3d-viewer library for rendering +export function newRenderPlugin3DViewer(): FileRenderPlugin { + // Some extensions are text-based formats: + // .3mf .amf .brep: XML + // .fbx: XML or BINARY + // .dae .gltf: JSON + // .ifc, .igs, .iges, .stp, .step are: TEXT + // .stl .ply: TEXT or BINARY + // .obj .off .wrl: TEXT + // TODO: So we need to be able to render when the file is recognized as plaintext file by backend const SUPPORTED_EXTENSIONS = [ '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', '.dae', '.fbx', '.fcstd', '.glb', '.gltf', @@ -15,17 +18,14 @@ export function register3DViewerPlugin(): void { '.stl', '.obj', '.off', '.ply', '.wrl', ]; - // create and register plugin - const plugin: FileRenderPlugin = { + return { name: '3d-model-viewer', - // check if file extension is a supported 3D file canHandle(filename: string, _mimeType: string): boolean { - const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + const ext = extname(filename).toLowerCase(); return SUPPORTED_EXTENSIONS.includes(ext); }, - // render 3D model async render(container: HTMLElement, fileUrl: string): Promise { const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); container.classList.add('model3d-content'); @@ -37,6 +37,4 @@ export function register3DViewerPlugin(): void { viewer.LoadModelFromUrlList([fileUrl]); }, }; - - registerFileRenderPlugin(plugin); } diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts index 741dd4d91711d..40623be05576f 100644 --- a/web_src/js/render/plugins/pdf-viewer.ts +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -1,12 +1,13 @@ -import type {FileRenderPlugin} from '../../modules/file-render-plugin.ts'; -import {registerFileRenderPlugin} from '../../modules/file-render-plugin.ts'; +import type {FileRenderPlugin} from '../plugin.ts'; -export function registerPdfViewerPlugin(): void { - const plugin: FileRenderPlugin = { +export function newRenderPluginPdfViewer(): FileRenderPlugin { + return { name: 'pdf-viewer', + canHandle(filename: string, _mimeType: string): boolean { return filename.toLowerCase().endsWith('.pdf'); }, + async render(container: HTMLElement, fileUrl: string): Promise { const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); // TODO: the PDFObject library does not support dynamic height adjustment, @@ -16,5 +17,4 @@ export function registerPdfViewerPlugin(): void { } }, }; - registerFileRenderPlugin(plugin); } From cade6400f290953eaae4226cdd8749a81b030401 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 13:46:18 +0800 Subject: [PATCH 14/19] fix --- modules/markup/console/console.go | 23 +- modules/typesniffer/typesniffer.go | 4 +- routers/web/repo/editor.go | 4 +- routers/web/repo/setting/lfs.go | 2 + routers/web/repo/view.go | 53 +-- routers/web/repo/view_file.go | 342 +++++++++--------- routers/web/repo/view_home.go | 2 +- routers/web/repo/view_readme.go | 11 +- templates/repo/blame.tmpl | 2 + templates/repo/editor/common_breadcrumb.tmpl | 2 +- templates/repo/settings/lfs_file.tmpl | 2 +- templates/repo/view_file.tmpl | 86 +++-- templates/shared/repo/fileviewrender.tmpl | 5 - web_src/js/features/copycontent.ts | 14 +- web_src/js/features/file-view.ts | 82 +++-- web_src/js/index.ts | 4 +- .../js/render/plugins/3d-viewer-test-cube.stl | 86 +++++ web_src/js/render/plugins/3d-viewer.ts | 2 +- 18 files changed, 417 insertions(+), 309 deletions(-) delete mode 100644 templates/shared/repo/fileviewrender.tmpl create mode 100644 web_src/js/render/plugins/3d-viewer-test-cube.stl diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go index 06f3acfa68948..1788f14c03dbf 100644 --- a/modules/markup/console/console.go +++ b/modules/markup/console/console.go @@ -6,13 +6,11 @@ package console import ( "bytes" "io" - "path" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" trend "github.com/buildkite/terminal-to-html/v3" - "github.com/go-enry/go-enry/v2" ) func init() { @@ -41,14 +39,19 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { // CanRender implements markup.RendererContentDetector func (Renderer) CanRender(filename string, input io.Reader) bool { - buf, err := io.ReadAll(input) - if err != nil { - return false - } - if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage { - return false - } - return bytes.ContainsRune(buf, '\x1b') + /* + buf, err := io.ReadAll(input) + if err != nil { + return false + } + if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage { + return false + } + return bytes.ContainsRune(buf, '\x1b') + */ + // FIXME: this check is not right, it is too broad and will match any file containing ANSI escape codes. + // So only use the defined "Extensions" to avoid conflicts with other renderers. + return false } // Render renders terminal colors to HTML with all specific handling stuff. diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go index 8cb3d278ce4aa..506124edf3158 100644 --- a/modules/typesniffer/typesniffer.go +++ b/modules/typesniffer/typesniffer.go @@ -32,12 +32,12 @@ var ( svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(|>))\s*)*= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - if fInfo.st.IsSvgImage() { - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true - ctx.Data["HasSourceRenderedToggle"] = true - } - - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) - - shouldRenderSource := ctx.FormString("display") == "source" - readmeExist := util.IsReadmeFileName(blob.Name()) - ctx.Data["ReadmeExist"] = readmeExist - - markupType := markup.DetectMarkupTypeByFileName(blob.Name()) - if markupType == "" { - markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) - } - if markupType != "" { - ctx.Data["HasSourceRenderedToggle"] = true - } - if markupType != "" && !shouldRenderSource { - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx) - metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL() - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath). - WithMetas(metas) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - // to prevent iframe load third-party url - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") - } else { - buf, _ := io.ReadAll(rd) - - // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html - // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; - // Gitea uses the definition (like most modern editors): - // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; - // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. - // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. - // This NumLines is only used for the display on the UI: "xxx lines" - if len(buf) == 0 { - ctx.Data["NumLines"] = 0 - } else { - ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 - } - - language := attrs.GetLanguage().Value() - fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) - ctx.Data["LexerName"] = lexerName - if err != nil { - log.Error("highlight.File failed, fallback to plain text: %v", err) - fileContent = highlight.PlainText(buf) - } - status := &charset.EscapeStatus{} - statuses := make([]*charset.EscapeStatus, len(fileContent)) - for i, line := range fileContent { - statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) - status = status.Or(statuses[i]) - } - ctx.Data["EscapeStatus"] = status - ctx.Data["FileContent"] = fileContent - ctx.Data["LineEscapeStatus"] = statuses - } - + case fInfo.fileSize >= setting.UI.MaxDisplayFileSize: + ctx.Data["IsFileTooLarge"] = true + case handleFileViewRenderMarkup(ctx, entry.Name(), buf, utf8Reader): + // it also sets ctx.Data["FileContent"] and more + ctx.Data["IsMarkup"] = true + case handleFileViewRenderSource(ctx, entry.Name(), attrs, fInfo, utf8Reader): + // it also sets ctx.Data["FileContent"] and more + ctx.Data["IsDisplayingSource"] = true + case handleFileViewRenderImage(ctx, fInfo, buf): + ctx.Data["IsImageFile"] = true case fInfo.st.IsVideo(): ctx.Data["IsVideoFile"] = true case fInfo.st.IsAudio(): ctx.Data["IsAudioFile"] = true - case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true default: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - // TODO: this logic duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" - // It is used by "external renders", markupRender will execute external programs to get rendered content. - if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType != "" { - rd := io.MultiReader(bytes.NewReader(buf), dataRc) - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - } + // unable to render anything, show the "view raw" or let frontend handle it } - - ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value() - - if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { - img, _, err := image.DecodeConfig(bytes.NewReader(buf)) - if err == nil { - // There are Image formats go can't decode - // Instead of throwing an error in that case, we show the size only when we can decode - ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) - } - } - - prepareToRenderButtons(ctx, lfsLock) } -func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { +func prepareFileViewEditorButtons(ctx *context.Context) bool { // archived or mirror repository, the buttons should not be shown if !ctx.Repo.Repository.CanEnableEditor() { - return + return true } // The buttons should not be shown if it's not a branch if !ctx.Repo.RefFullName.IsBranch() { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - return + return true } if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { @@ -304,7 +274,24 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") ctx.Data["CanDeleteFile"] = true ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") - return + return true + } + + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return false + } + if lfsLock != nil { + u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return false + } + ctx.Data["LFSLockOwner"] = u.Name + ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") } // it's a lfs file and the user is not the owner of the lock @@ -313,4 +300,5 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file")) ctx.Data["CanDeleteFile"] = !isLFSLocked ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file")) + return true } diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 48fa47d738426..8ed9179290555 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -339,7 +339,7 @@ func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) { if entry.IsDir() { prepareToRenderDirectory(ctx) } else { - prepareToRenderFile(ctx, entry) + prepareFileView(ctx, entry) } } } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 4ce22d79db5ab..a34de06e8ef71 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -161,24 +161,23 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil } defer dataRc.Close() - ctx.Data["FileIsText"] = fInfo.isTextFile + ctx.Data["FileIsText"] = fInfo.st.IsText() ctx.Data["FileTreePath"] = path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name()) ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["IsLFSFile"] = fInfo.isLFSFile() - if fInfo.isLFSFile { + if fInfo.isLFSFile() { filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) } - if !fInfo.isTextFile { + if !fInfo.st.IsText() { return } if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { // Pretend that this is a normal text file to display 'This file is too large to be shown' ctx.Data["IsFileTooLarge"] = true - ctx.Data["IsTextFile"] = true return } @@ -212,7 +211,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) } - if !fInfo.isLFSFile && ctx.Repo.Repository.CanEnableEditor() { + if !fInfo.isLFSFile() && ctx.Repo.Repository.CanEnableEditor() { ctx.Data["CanEditReadmeFile"] = true } } diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 9596fe837ae18..c4d9f0741f2de 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -82,6 +82,8 @@ {{end}}{{/* end if .IsFileTooLarge */}}
+ {{/*FIXME: the "HasSourceRenderedToggle" is never set on blame page, it should mean "whether the file is renderable". + If the file is renderable, then it must has the "display=source" parameter to make sure the file view page shows the source code, then line number works. */}} {{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} {{ctx.Locale.Tr "repo.issues.context.reference_issue"}} {{end}} diff --git a/templates/repo/editor/common_breadcrumb.tmpl b/templates/repo/editor/common_breadcrumb.tmpl index df36f005042c5..8cfbe09d3eef5 100644 --- a/templates/repo/editor/common_breadcrumb.tmpl +++ b/templates/repo/editor/common_breadcrumb.tmpl @@ -5,7 +5,7 @@ {{range $i, $v := .TreeNames}} {{if eq $i $l}} - + {{svg "octicon-info"}} {{else}} {{$v}} diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl index 640ebf022f04a..cd1b168401d45 100644 --- a/templates/repo/settings/lfs_file.tmpl +++ b/templates/repo/settings/lfs_file.tmpl @@ -31,7 +31,7 @@ {{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}} {{else}} - {{template "shared/repo/fileviewrender" dict "RawFileLink" $.RawFileLink}} + {{ctx.Locale.Tr "repo.file_view_raw"}} {{end}}
{{else if .FileSize}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index f27b856674cae..3c80f91672716 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -1,4 +1,6 @@ -
+
+ {{- if .FileError}}
{{.FileError}}
@@ -32,13 +34,14 @@ {{template "repo/file_info" .}} {{end}}
-
- {{if .HasSourceRenderedToggle}} - - {{end}} +
+ {{/* this componment is also controlled by frontend plugin renders */}} +
+ {{if .IsRepresentableAsText}} + {{svg "octicon-code" 15}} + {{end}} + {{svg "octicon-file" 15}} +
{{if not .ReadmeInList}}
{{ctx.Locale.Tr "repo.file_raw"}} @@ -55,7 +58,10 @@ {{end}}
{{svg "octicon-download"}} - {{svg "octicon-copy"}} + {{svg "octicon-copy"}} {{if .EnableFeed}} {{svg "octicon-rss"}} @@ -82,39 +88,24 @@ {{end}}
+
- {{if not (or .IsMarkup .IsRenderedHTML)}} - {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}} + {{if not .IsMarkup}} + {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} -
+
{{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} {{else if not .FileSize}} {{template "shared/fileisempty"}} {{else if .IsMarkup}} - {{if .FileContent}}{{.FileContent}}{{end}} + {{.FileContent}} {{else if .IsPlainText}}
{{if .FileContent}}{{.FileContent}}{{end}}
- {{else if not .IsTextSource}} -
- {{if .IsImageFile}} - {{$.RawFileLink}} - {{else if .IsVideoFile}} - - {{else if .IsAudioFile}} - - {{else}} - {{template "shared/repo/fileviewrender" dict "RawFileLink" $.RawFileLink}} - {{end}} -
- {{else if .FileSize}} + {{else if .FileContent}} - {{range $idx, $code := .FileContent}} + {{range $idx, $code := .FileContent}} {{$line := Eval $idx "+" 1}} @@ -123,17 +114,38 @@ {{end}} - {{end}} + {{end}}
{{$code}}
-
- {{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} - {{ctx.Locale.Tr "repo.issues.context.reference_issue"}} + {{else}} +
+ {{if .IsImageFile}} + {{$.RawFileLink}} + {{else if .IsVideoFile}} + + {{else if .IsAudioFile}} + + {{else}} + {{end}} - {{ctx.Locale.Tr "repo.view_git_blame"}} - {{ctx.Locale.Tr "repo.file_copy_permalink"}}
{{end}}
+ +
diff --git a/templates/shared/repo/fileviewrender.tmpl b/templates/shared/repo/fileviewrender.tmpl deleted file mode 100644 index cb739bcfff289..0000000000000 --- a/templates/shared/repo/fileviewrender.tmpl +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index d58f6c8246660..51b9df0335b7a 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -9,17 +9,17 @@ const {i18n} = window.config; export function initCopyContent() { registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => { if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return; - let content; - let isRasterImage = false; - const link = btn.getAttribute('data-link'); + const rawLink = btn.getAttribute('data-raw-link'); - // when data-link is present, we perform a fetch. this is either because - // the text to copy is not in the DOM, or it is an image which should be + let content, isRasterImage = false; + + // when "data-raw-link" is present, we perform a fetch. this is either because + // the text to copy is not in the DOM, or it is an image that should be // fetched to copy in full resolution - if (link) { + if (rawLink) { btn.classList.add('is-loading', 'loading-icon-2px'); try { - const res = await GET(link, {credentials: 'include', redirect: 'follow'}); + const res = await GET(rawLink, {credentials: 'include', redirect: 'follow'}); const contentType = res.headers.get('content-type'); if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 41a422163b033..3532a63dd66d7 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -2,7 +2,7 @@ import type {FileRenderPlugin} from '../render/plugin.ts'; import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; -import {createElementFromHTML} from '../utils/dom.ts'; +import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts'; import {htmlEscape} from 'escape-goat'; import {basename} from '../utils.ts'; @@ -17,41 +17,59 @@ function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlu return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; } -export function initFileViewRender(): void { - registerGlobalInitFunc('initFileViewRender', async (container: HTMLElement) => { - initPluginsOnce(); +function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void { + const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons'); + showElem(toggleButtons); + const displayingRendered = Boolean(renderContainer); + toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist + toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered); +} - const rawFileLink = container.getAttribute('data-raw-file-link'); - const mimeType = container.getAttribute('data-mime-type') || ''; // not used yet - const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); - if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); - - let rendered = false, errorMsg = ''; - try { - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); - if (plugin) { - container.classList.add('is-loading'); - container.setAttribute('data-render-name', plugin.name); // not used yet - await plugin.render(container, rawFileLink); - rendered = true; - } - } catch (e) { - errorMsg = `${e}`; - } finally { - container.classList.remove('is-loading'); - } +async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { + const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); + if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); - if (rendered) { - elViewRawPrompt.remove(); - return; + let rendered = false, errorMsg = ''; + try { + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (plugin) { + container.classList.add('is-loading'); + container.setAttribute('data-render-name', plugin.name); // not used yet + await plugin.render(container, rawFileLink); + rendered = true; } + } catch (e) { + errorMsg = `${e}`; + } finally { + container.classList.remove('is-loading'); + } - // remove all children from the container, and only show the raw file link - container.replaceChildren(elViewRawPrompt); + if (rendered) { + elViewRawPrompt.remove(); + return; + } - if (errorMsg) { - const elErrorMessage = createElementFromHTML(htmlEscape`
${errorMsg}
`); - elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage); - } + // remove all children from the container, and only show the raw file link + container.replaceChildren(elViewRawPrompt); + + if (errorMsg) { + const elErrorMessage = createElementFromHTML(htmlEscape`
${errorMsg}
`); + elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage); + } +} + +export function initRepoFileView(): void { + registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { + initPluginsOnce(); + const rawFileLink = elFileView.getAttribute('data-raw-file-link'); + const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet + // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (!plugin) return; + + const renderContainer = elFileView.querySelector('.file-view-render-container'); + showRenderRawFileButton(elFileView, renderContainer); + // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it + if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType); }); } diff --git a/web_src/js/index.ts b/web_src/js/index.ts index eb51c52088a15..347aad270997c 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -19,7 +19,7 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; -import {initFileViewRender} from './features/file-view.ts'; +import {initRepoFileView} from './features/file-view.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; @@ -163,7 +163,7 @@ onDomReady(() => { initOAuth2SettingsDisableCheckbox, - initFileViewRender, + initRepoFileView, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/render/plugins/3d-viewer-test-cube.stl b/web_src/js/render/plugins/3d-viewer-test-cube.stl new file mode 100644 index 0000000000000..86e86f933e4fa --- /dev/null +++ b/web_src/js/render/plugins/3d-viewer-test-cube.stl @@ -0,0 +1,86 @@ +solid cube + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 1.000000e+00 0.000000e+00 0.000000e+00 + vertex 1.000000e+00 1.000000e+00 0.000000e+00 + vertex 1.000000e+00 0.000000e+00 1.000000e+00 + endloop + endfacet + facet normal 1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 1.000000e+00 1.000000e+00 0.000000e+00 + vertex 1.000000e+00 1.000000e+00 1.000000e+00 + vertex 1.000000e+00 0.000000e+00 1.000000e+00 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 0.000000e+00 0.000000e+00 1.000000e+00 + vertex 0.000000e+00 1.000000e+00 0.000000e+00 + endloop + endfacet + facet normal -1.000000e+00 0.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 1.000000e+00 + vertex 0.000000e+00 1.000000e+00 1.000000e+00 + vertex 0.000000e+00 1.000000e+00 0.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 1.000000e+00 0.000000e+00 + vertex 0.000000e+00 1.000000e+00 1.000000e+00 + vertex 1.000000e+00 1.000000e+00 0.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 1.000000e+00 0.000000e+00 + outer loop + vertex 1.000000e+00 1.000000e+00 0.000000e+00 + vertex 0.000000e+00 1.000000e+00 1.000000e+00 + vertex 1.000000e+00 1.000000e+00 1.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 1.000000e+00 0.000000e+00 0.000000e+00 + vertex 0.000000e+00 0.000000e+00 1.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 + outer loop + vertex 1.000000e+00 0.000000e+00 0.000000e+00 + vertex 1.000000e+00 0.000000e+00 1.000000e+00 + vertex 0.000000e+00 0.000000e+00 1.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 1.000000e+00 + vertex 1.000000e+00 0.000000e+00 1.000000e+00 + vertex 0.000000e+00 1.000000e+00 1.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 1.000000e+00 + outer loop + vertex 1.000000e+00 0.000000e+00 1.000000e+00 + vertex 1.000000e+00 1.000000e+00 1.000000e+00 + vertex 0.000000e+00 1.000000e+00 1.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 0.000000e+00 0.000000e+00 0.000000e+00 + vertex 0.000000e+00 1.000000e+00 0.000000e+00 + vertex 1.000000e+00 0.000000e+00 0.000000e+00 + endloop + endfacet + facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 + outer loop + vertex 0.000000e+00 1.000000e+00 0.000000e+00 + vertex 1.000000e+00 1.000000e+00 0.000000e+00 + vertex 1.000000e+00 0.000000e+00 0.000000e+00 + endloop + endfacet +endsolid cube diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index 51687cec11f76..a7711baa82bfc 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -10,7 +10,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin { // .ifc, .igs, .iges, .stp, .step are: TEXT // .stl .ply: TEXT or BINARY // .obj .off .wrl: TEXT - // TODO: So we need to be able to render when the file is recognized as plaintext file by backend + // So we need to be able to render when the file is recognized as plaintext file by backend const SUPPORTED_EXTENSIONS = [ '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', '.dae', '.fbx', '.fcstd', '.glb', '.gltf', From 81bd73c7cb21183d00fed67822516cfd8d89c4bc Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 14:49:58 +0800 Subject: [PATCH 15/19] fix --- modules/markup/console/console.go | 53 ++++++++++++++++++++------ modules/markup/console/console_test.go | 28 ++++++++++---- modules/markup/renderer.go | 12 ++---- modules/typesniffer/typesniffer.go | 30 +++++++++++---- routers/web/repo/view_file.go | 18 +++++---- tests/integration/lfs_view_test.go | 12 ++---- 6 files changed, 102 insertions(+), 51 deletions(-) diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go index 1788f14c03dbf..8f442c9c188ef 100644 --- a/modules/markup/console/console.go +++ b/modules/markup/console/console.go @@ -6,9 +6,12 @@ package console import ( "bytes" "io" + "unicode/utf8" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" trend "github.com/buildkite/terminal-to-html/v3" ) @@ -20,6 +23,8 @@ func init() { // Renderer implements markup.Renderer type Renderer struct{} +var _ markup.RendererContentDetector = (*Renderer)(nil) + // Name implements markup.Renderer func (Renderer) Name() string { return "console" @@ -38,20 +43,46 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { } // CanRender implements markup.RendererContentDetector -func (Renderer) CanRender(filename string, input io.Reader) bool { - /* - buf, err := io.ReadAll(input) - if err != nil { +func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool { + if !sniffedType.IsTextPlain() { + return false + } + + s := util.UnsafeBytesToString(prefetchBuf) + rs := []rune(s) + cnt := 0 + firstErrPos := -1 + for i, c := range rs { + if c == 0 { return false } - if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage { - return false + if c == '\x1b' { + match, c2, c3, c4, c5 := false, false, false, false, false + if i+2 < len(rs) { + match = rs[i+1] == '[' + c2 = rs[i+2] == ';' || rs[i+2] == 'm' + } + if i+3 < len(rs) { + c3 = rs[i+3] == ';' || rs[i+3] == 'm' + } + if i+4 < len(rs) { + c4 = rs[i+4] == ';' || rs[i+4] == 'm' + } + if i+5 < len(rs) { + c5 = rs[i+5] == ';' || rs[i+5] == 'm' + } + if match && (c2 || c3 || c4 || c5) { + cnt++ + } } - return bytes.ContainsRune(buf, '\x1b') - */ - // FIXME: this check is not right, it is too broad and will match any file containing ANSI escape codes. - // So only use the defined "Extensions" to avoid conflicts with other renderers. - return false + if c == utf8.RuneError && firstErrPos == -1 { + firstErrPos = i + } + } + if firstErrPos != -1 && firstErrPos != len(rs)-1 { + return false + } + return cnt >= 2 } // Render renders terminal colors to HTML with all specific handling stuff. diff --git a/modules/markup/console/console_test.go b/modules/markup/console/console_test.go index 539f965ea17b8..f140df5596b67 100644 --- a/modules/markup/console/console_test.go +++ b/modules/markup/console/console_test.go @@ -8,23 +8,35 @@ import ( "testing" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/typesniffer" "github.com/stretchr/testify/assert" ) func TestRenderConsole(t *testing.T) { - var render Renderer - kases := map[string]string{ - "\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok": "npm info it worked if it ends with ok", + cases := []struct { + input string + expected string + }{ + {"\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok", `npm info it worked if it ends with ok`}, + {"\x1b[1;2m \x1b[123m 啊", ``}, + {"\x1b[1;2m \x1b[123m \xef", ``}, + {"\x1b[1;2m \x1b[123m \xef \xef", ``}, } - for k, v := range kases { + var render Renderer + for i, c := range cases { var buf strings.Builder - canRender := render.CanRender("test", strings.NewReader(k)) - assert.True(t, canRender) + st := typesniffer.DetectContentType([]byte(c.input)) + canRender := render.CanRender("test", st, []byte(c.input)) + if c.expected == "" { + assert.False(t, canRender, "case %d: expected not to render", i) + continue + } - err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(k), &buf) + assert.True(t, canRender) + err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(c.input), &buf) assert.NoError(t, err) - assert.Equal(t, v, buf.String()) + assert.Equal(t, c.expected, buf.String()) } } diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 35f90eb46cbd9..b6e9c348b7319 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -4,12 +4,12 @@ package markup import ( - "bytes" "io" "path" "strings" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" ) // Renderer defines an interface for rendering markup file to HTML @@ -37,7 +37,7 @@ type ExternalRenderer interface { // RendererContentDetector detects if the content can be rendered // by specified renderer type RendererContentDetector interface { - CanRender(filename string, input io.Reader) bool + CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool } var ( @@ -60,13 +60,9 @@ func GetRendererByFileName(filename string) Renderer { } // DetectRendererType detects the markup type of the content -func DetectRendererType(filename string, input io.Reader) string { - buf, err := io.ReadAll(input) - if err != nil { - return "" - } +func DetectRendererType(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) string { for _, renderer := range renderers { - if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, bytes.NewReader(buf)) { + if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, sniffedType, prefetchBuf) { return renderer.Name() } } diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go index 506124edf3158..785273c901811 100644 --- a/modules/typesniffer/typesniffer.go +++ b/modules/typesniffer/typesniffer.go @@ -12,6 +12,7 @@ import ( "regexp" "slices" "strings" + "sync" "code.gitea.io/gitea/modules/util" ) @@ -26,11 +27,15 @@ const ( MimeTypeApplicationOctetStream = "application/octet-stream" ) -var ( - svgComment = regexp.MustCompile(`(?s)`) - svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(|>))\s*)*\s*(?:(|>))\s*)*`) + ret.svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(|>))\s*)*\s*(?:(|>))\s*)*= setting.UI.MaxDisplayFileSize: ctx.Data["IsFileTooLarge"] = true - case handleFileViewRenderMarkup(ctx, entry.Name(), buf, utf8Reader): + case handleFileViewRenderMarkup(ctx, entry.Name(), fInfo.st, buf, utf8Reader): // it also sets ctx.Data["FileContent"] and more ctx.Data["IsMarkup"] = true case handleFileViewRenderSource(ctx, entry.Name(), attrs, fInfo, utf8Reader): diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go index 9d8c9af2a122c..c26ece22bee46 100644 --- a/tests/integration/lfs_view_test.go +++ b/tests/integration/lfs_view_test.go @@ -68,19 +68,15 @@ func TestLFSRender(t *testing.T) { req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/crypt.bin") resp := session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body).doc + doc := NewHTMLParser(t, resp.Body) fileInfo := doc.Find("div.file-info-entry").First().Text() assert.Contains(t, fileInfo, "LFS") // find new file view container - fileViewContainer := doc.Find("div.file-view-render-container") - assert.Positive(t, fileViewContainer.Length(), "File view container should exist") - - // check data attribute instead of link href - dataURL, exists := fileViewContainer.Attr("data-raw-file-link") - assert.True(t, exists, "File view container should have data-raw-file-link attribute") - assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", dataURL, "The data-raw-file-link should use the proper /media link because it's in LFS") + fileViewContainer := doc.Find("[data-global-init=initRepoFileView]") + assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", fileViewContainer.AttrOr("data-raw-file-link", "")) + AssertHTMLElement(t, doc, ".view-raw > .file-view-render-container > .file-view-raw-prompt", 1) }) // check that a directory with a README file shows its text From eda267d03778b8f805aba9a45a26cec64db826ed Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 15:16:31 +0800 Subject: [PATCH 16/19] refactor DetectContentTypeFromReader --- modules/git/blob.go | 21 +++++++------ modules/typesniffer/typesniffer.go | 39 ++++++------------------- modules/typesniffer/typesniffer_test.go | 2 +- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/modules/git/blob.go b/modules/git/blob.go index ab9deec8d1c2c..40d8f44e799d6 100644 --- a/modules/git/blob.go +++ b/modules/git/blob.go @@ -22,17 +22,22 @@ func (b *Blob) Name() string { return b.name } -// GetBlobContent Gets the limited content of the blob as raw text -func (b *Blob) GetBlobContent(limit int64) (string, error) { +// GetBlobBytes Gets the limited content of the blob +func (b *Blob) GetBlobBytes(limit int64) ([]byte, error) { if limit <= 0 { - return "", nil + return nil, nil } dataRc, err := b.DataAsync() if err != nil { - return "", err + return nil, err } defer dataRc.Close() - buf, err := util.ReadWithLimit(dataRc, int(limit)) + return util.ReadWithLimit(dataRc, int(limit)) +} + +// GetBlobContent Gets the limited content of the blob as raw text +func (b *Blob) GetBlobContent(limit int64) (string, error) { + buf, err := b.GetBlobBytes(limit) return string(buf), err } @@ -99,11 +104,9 @@ loop: // GuessContentType guesses the content type of the blob. func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) { - r, err := b.DataAsync() + buf, err := b.GetBlobBytes(typesniffer.SniffContentSize) if err != nil { return typesniffer.SniffedType{}, err } - defer r.Close() - - return typesniffer.DetectContentTypeFromReader(r) + return typesniffer.DetectContentType(buf), nil } diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go index 785273c901811..2e8d9c4a1e727 100644 --- a/modules/typesniffer/typesniffer.go +++ b/modules/typesniffer/typesniffer.go @@ -6,19 +6,14 @@ package typesniffer import ( "bytes" "encoding/binary" - "fmt" - "io" "net/http" "regexp" "slices" "strings" "sync" - - "code.gitea.io/gitea/modules/util" ) -// Use at most this many bytes to determine Content Type. -const sniffLen = 1024 +const SniffContentSize = 1024 const ( MimeTypeImageSvg = "image/svg+xml" @@ -42,7 +37,7 @@ type SniffedType struct { contentType string } -// IsText detects if the content format is plain text. +// IsText detects if the content format is text family, including text/plain, text/html, text/css, etc. func (ct SniffedType) IsText() bool { return strings.Contains(ct.contentType, "text/") } @@ -66,12 +61,12 @@ func (ct SniffedType) IsPDF() bool { return strings.Contains(ct.contentType, "application/pdf") } -// IsVideo detects if data is an video format +// IsVideo detects if data is a video format func (ct SniffedType) IsVideo() bool { return strings.Contains(ct.contentType, "video/") } -// IsAudio detects if data is an video format +// IsAudio detects if data is a video format func (ct SniffedType) IsAudio() bool { return strings.Contains(ct.contentType, "audio/") } @@ -87,10 +82,6 @@ func (ct SniffedType) IsBrowsableBinaryType() bool { return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio() } -func (ct SniffedType) IsApplicationOctetStream() bool { - return ct.contentType == "application/octet-stream" -} - // GetMimeType returns the mime type func (ct SniffedType) GetMimeType() string { return strings.SplitN(ct.contentType, ";", 2)[0] @@ -116,16 +107,16 @@ func detectFileTypeBox(data []byte) (brands []string, found bool) { return brands, true } -// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty. +// DetectContentType extends http.DetectContentType with more content types. Defaults to text/plain if input is empty. func DetectContentType(data []byte) SniffedType { if len(data) == 0 { - return SniffedType{"text/unknown"} + return SniffedType{"text/plain"} } ct := http.DetectContentType(data) - if len(data) > sniffLen { - data = data[:sniffLen] + if len(data) > SniffContentSize { + data = data[:SniffContentSize] } vars := globalVars() @@ -143,7 +134,7 @@ func DetectContentType(data []byte) SniffedType { if strings.HasPrefix(ct, "audio/") && bytes.HasPrefix(data, []byte("ID3")) { // The MP3 detection is quite inaccurate, any content with "ID3" prefix will result in "audio/mpeg". - // So remove the "ID3" prefix and detect again, if result is text, then it must be text content. + // So remove the "ID3" prefix and detect again, then if the result is "text", it must be text content. // This works especially because audio files contain many unprintable/invalid characters like `0x00` ct2 := http.DetectContentType(data[3:]) if strings.HasPrefix(ct2, "text/") { @@ -169,15 +160,3 @@ func DetectContentType(data []byte) SniffedType { } return SniffedType{ct} } - -// DetectContentTypeFromReader guesses the content type contained in the reader. -func DetectContentTypeFromReader(r io.Reader) (SniffedType, error) { - buf := make([]byte, sniffLen) - n, err := util.ReadAtMost(r, buf) - if err != nil { - return SniffedType{}, fmt.Errorf("DetectContentTypeFromReader io error: %w", err) - } - buf = buf[:n] - - return DetectContentType(buf), nil -} diff --git a/modules/typesniffer/typesniffer_test.go b/modules/typesniffer/typesniffer_test.go index 3e5db3308b5a4..51c6bb44c7c94 100644 --- a/modules/typesniffer/typesniffer_test.go +++ b/modules/typesniffer/typesniffer_test.go @@ -17,7 +17,7 @@ func TestDetectContentTypeLongerThanSniffLen(t *testing.T) { // Pre-condition: Shorter than sniffLen detects SVG. assert.Equal(t, "image/svg+xml", DetectContentType([]byte(``)).contentType) // Longer than sniffLen detects something else. - assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(``)).contentType) + assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(``)).contentType) } func TestIsTextFile(t *testing.T) { From 635c0b838e5061a1395b9ae1740e88d84f46a89f Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 15:25:16 +0800 Subject: [PATCH 17/19] fix test --- modules/typesniffer/typesniffer_test.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/modules/typesniffer/typesniffer_test.go b/modules/typesniffer/typesniffer_test.go index 51c6bb44c7c94..a0c824b912e25 100644 --- a/modules/typesniffer/typesniffer_test.go +++ b/modules/typesniffer/typesniffer_test.go @@ -4,7 +4,6 @@ package typesniffer import ( - "bytes" "encoding/base64" "encoding/hex" "strings" @@ -116,22 +115,13 @@ func TestIsAudio(t *testing.T) { assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char } -func TestDetectContentTypeFromReader(t *testing.T) { - mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl") - st, err := DetectContentTypeFromReader(bytes.NewReader(mp3)) - assert.NoError(t, err) - assert.True(t, st.IsAudio()) -} - func TestDetectContentTypeOgg(t *testing.T) { oggAudio, _ := hex.DecodeString("4f67675300020000000000000000352f0000000000007dc39163011e01766f72626973000000000244ac0000000000000071020000000000b8014f6767530000") - st, err := DetectContentTypeFromReader(bytes.NewReader(oggAudio)) - assert.NoError(t, err) + st := DetectContentType(oggAudio) assert.True(t, st.IsAudio()) oggVideo, _ := hex.DecodeString("4f676753000200000000000000007d9747ef000000009b59daf3012a807468656f7261030201001e00110001e000010e00020000001e00000001000001000001") - st, err = DetectContentTypeFromReader(bytes.NewReader(oggVideo)) - assert.NoError(t, err) + st = DetectContentType(oggVideo) assert.True(t, st.IsVideo()) } From b28f4785c0e06e1c4a8c792264648721010cf0e1 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 29 Jun 2025 16:08:49 +0800 Subject: [PATCH 18/19] fine tune --- routers/web/repo/view_file.go | 5 +- templates/repo/view_file.tmpl | 2 +- web_src/js/features/copycontent.ts | 6 +- .../js/render/plugins/3d-viewer-test-cube.stl | 86 ------------------- web_src/js/render/plugins/3d-viewer.ts | 15 +++- 5 files changed, 20 insertions(+), 94 deletions(-) delete mode 100644 web_src/js/render/plugins/3d-viewer-test-cube.stl diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 331f1dc5c203d..f031e9d99a7fb 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -225,12 +225,11 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { return } - isRepresentableAsText := fInfo.st.IsRepresentableAsText() ctx.Data["IsLFSFile"] = fInfo.isLFSFile() ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsRepresentableAsText"] = isRepresentableAsText + ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText() ctx.Data["IsExecutable"] = entry.IsExecutable() - ctx.Data["CanCopyContent"] = isRepresentableAsText + ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage() attrs, ok := prepareFileViewLfsAttrs(ctx) if !ok { diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 3c80f91672716..1486d7181df8e 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -59,7 +59,7 @@
{{svg "octicon-download"}} {{svg "octicon-copy"}} {{if .EnableFeed}} diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index 51b9df0335b7a..0fec2a6235fa1 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -9,17 +9,17 @@ const {i18n} = window.config; export function initCopyContent() { registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => { if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return; - const rawLink = btn.getAttribute('data-raw-link'); + const rawFileLink = btn.getAttribute('data-raw-file-link'); let content, isRasterImage = false; // when "data-raw-link" is present, we perform a fetch. this is either because // the text to copy is not in the DOM, or it is an image that should be // fetched to copy in full resolution - if (rawLink) { + if (rawFileLink) { btn.classList.add('is-loading', 'loading-icon-2px'); try { - const res = await GET(rawLink, {credentials: 'include', redirect: 'follow'}); + const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'}); const contentType = res.headers.get('content-type'); if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { diff --git a/web_src/js/render/plugins/3d-viewer-test-cube.stl b/web_src/js/render/plugins/3d-viewer-test-cube.stl deleted file mode 100644 index 86e86f933e4fa..0000000000000 --- a/web_src/js/render/plugins/3d-viewer-test-cube.stl +++ /dev/null @@ -1,86 +0,0 @@ -solid cube - facet normal 1.000000e+00 0.000000e+00 0.000000e+00 - outer loop - vertex 1.000000e+00 0.000000e+00 0.000000e+00 - vertex 1.000000e+00 1.000000e+00 0.000000e+00 - vertex 1.000000e+00 0.000000e+00 1.000000e+00 - endloop - endfacet - facet normal 1.000000e+00 0.000000e+00 0.000000e+00 - outer loop - vertex 1.000000e+00 1.000000e+00 0.000000e+00 - vertex 1.000000e+00 1.000000e+00 1.000000e+00 - vertex 1.000000e+00 0.000000e+00 1.000000e+00 - endloop - endfacet - facet normal -1.000000e+00 0.000000e+00 0.000000e+00 - outer loop - vertex 0.000000e+00 0.000000e+00 0.000000e+00 - vertex 0.000000e+00 0.000000e+00 1.000000e+00 - vertex 0.000000e+00 1.000000e+00 0.000000e+00 - endloop - endfacet - facet normal -1.000000e+00 0.000000e+00 0.000000e+00 - outer loop - vertex 0.000000e+00 0.000000e+00 1.000000e+00 - vertex 0.000000e+00 1.000000e+00 1.000000e+00 - vertex 0.000000e+00 1.000000e+00 0.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 1.000000e+00 0.000000e+00 - outer loop - vertex 0.000000e+00 1.000000e+00 0.000000e+00 - vertex 0.000000e+00 1.000000e+00 1.000000e+00 - vertex 1.000000e+00 1.000000e+00 0.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 1.000000e+00 0.000000e+00 - outer loop - vertex 1.000000e+00 1.000000e+00 0.000000e+00 - vertex 0.000000e+00 1.000000e+00 1.000000e+00 - vertex 1.000000e+00 1.000000e+00 1.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 - outer loop - vertex 0.000000e+00 0.000000e+00 0.000000e+00 - vertex 1.000000e+00 0.000000e+00 0.000000e+00 - vertex 0.000000e+00 0.000000e+00 1.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 - outer loop - vertex 1.000000e+00 0.000000e+00 0.000000e+00 - vertex 1.000000e+00 0.000000e+00 1.000000e+00 - vertex 0.000000e+00 0.000000e+00 1.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 0.000000e+00 1.000000e+00 - outer loop - vertex 0.000000e+00 0.000000e+00 1.000000e+00 - vertex 1.000000e+00 0.000000e+00 1.000000e+00 - vertex 0.000000e+00 1.000000e+00 1.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 0.000000e+00 1.000000e+00 - outer loop - vertex 1.000000e+00 0.000000e+00 1.000000e+00 - vertex 1.000000e+00 1.000000e+00 1.000000e+00 - vertex 0.000000e+00 1.000000e+00 1.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 - outer loop - vertex 0.000000e+00 0.000000e+00 0.000000e+00 - vertex 0.000000e+00 1.000000e+00 0.000000e+00 - vertex 1.000000e+00 0.000000e+00 0.000000e+00 - endloop - endfacet - facet normal 0.000000e+00 0.000000e+00 -1.000000e+00 - outer loop - vertex 0.000000e+00 1.000000e+00 0.000000e+00 - vertex 1.000000e+00 1.000000e+00 0.000000e+00 - vertex 1.000000e+00 0.000000e+00 0.000000e+00 - endloop - endfacet -endsolid cube diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index a7711baa82bfc..ab4d92f08c2ab 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -2,6 +2,20 @@ import type {FileRenderPlugin} from '../plugin.ts'; import {extname} from '../../utils.ts'; // support common 3D model file formats, use online-3d-viewer library for rendering + +// eslint-disable-next-line multiline-comment-style +/* a simple text STL file example: +solid SimpleTriangle + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid SimpleTriangle +*/ + export function newRenderPlugin3DViewer(): FileRenderPlugin { // Some extensions are text-based formats: // .3mf .amf .brep: XML @@ -28,7 +42,6 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin { async render(container: HTMLElement, fileUrl: string): Promise { const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); - container.classList.add('model3d-content'); const viewer = new OV.EmbeddedViewer(container, { backgroundColor: new OV.RGBAColor(59, 68, 76, 0), defaultColor: new OV.RGBColor(65, 131, 196), From 340fb0ea0bd65e19a4fd61a3e237b149b94ad223 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Jun 2025 14:15:10 +0800 Subject: [PATCH 19/19] fine tune and add more comments --- modules/markup/console/console.go | 22 ++++++---------------- modules/markup/console/console_test.go | 4 ++++ routers/web/repo/view_file.go | 5 +++++ web_src/js/features/file-view.ts | 1 + web_src/js/render/plugins/3d-viewer.ts | 9 ++++++++- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go index 8f442c9c188ef..492579b0a5027 100644 --- a/modules/markup/console/console.go +++ b/modules/markup/console/console.go @@ -52,26 +52,16 @@ func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, rs := []rune(s) cnt := 0 firstErrPos := -1 + isCtrlSep := func(p int) bool { + return p < len(rs) && (rs[p] == ';' || rs[p] == 'm') + } for i, c := range rs { if c == 0 { return false } if c == '\x1b' { - match, c2, c3, c4, c5 := false, false, false, false, false - if i+2 < len(rs) { - match = rs[i+1] == '[' - c2 = rs[i+2] == ';' || rs[i+2] == 'm' - } - if i+3 < len(rs) { - c3 = rs[i+3] == ';' || rs[i+3] == 'm' - } - if i+4 < len(rs) { - c4 = rs[i+4] == ';' || rs[i+4] == 'm' - } - if i+5 < len(rs) { - c5 = rs[i+5] == ';' || rs[i+5] == 'm' - } - if match && (c2 || c3 || c4 || c5) { + match := i+1 < len(rs) && rs[i+1] == '[' + if match && (isCtrlSep(i+2) || isCtrlSep(i+3) || isCtrlSep(i+4) || isCtrlSep(i+5)) { cnt++ } } @@ -82,7 +72,7 @@ func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, if firstErrPos != -1 && firstErrPos != len(rs)-1 { return false } - return cnt >= 2 + return cnt >= 2 // only render it as console output if there are at least two escape sequences } // Render renders terminal colors to HTML with all specific handling stuff. diff --git a/modules/markup/console/console_test.go b/modules/markup/console/console_test.go index f140df5596b67..d1192bebc2aad 100644 --- a/modules/markup/console/console_test.go +++ b/modules/markup/console/console_test.go @@ -22,6 +22,10 @@ func TestRenderConsole(t *testing.T) { {"\x1b[1;2m \x1b[123m 啊", ``}, {"\x1b[1;2m \x1b[123m \xef", ``}, {"\x1b[1;2m \x1b[123m \xef \xef", ``}, + {"\x1b[12", ``}, + {"\x1b[1", ``}, + {"\x1b[FOO\x1b[", ``}, + {"\x1b[mFOO\x1b[m", `FOO`}, } var render Renderer diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index f031e9d99a7fb..2d5bddd939f10 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -236,6 +236,11 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { return } + // TODO: in the future maybe we need more accurate flags, for example: + // * IsRepresentableAsText: some files are text, some are not + // * IsRenderableXxx: some files are rendered by backend "markup" engine, some are rendered by frontend (pdf, 3d) + // * DefaultViewMode: when there is no "display" query parameter, which view mode should be used by default, source or rendered + utf8Reader := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) switch { case fInfo.fileSize >= setting.UI.MaxDisplayFileSize: diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 3532a63dd66d7..867f9462975cc 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -23,6 +23,7 @@ function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLE const displayingRendered = Boolean(renderContainer); toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered); + // TODO: if there is only one button, hide it? } async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index ab4d92f08c2ab..2a0929359de2b 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -24,7 +24,13 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin { // .ifc, .igs, .iges, .stp, .step are: TEXT // .stl .ply: TEXT or BINARY // .obj .off .wrl: TEXT - // So we need to be able to render when the file is recognized as plaintext file by backend + // So we need to be able to render when the file is recognized as plaintext file by backend. + // + // It needs more logic to make it overall right (render a text 3D model automatically): + // we need to distinguish the ambiguous filename extensions. + // For example: "*.obj, *.off, *.step" might be or not be a 3D model file. + // So when it is a text file, we can't assume that "we only render it by 3D plugin", + // otherwise the end users would be impossible to view its real content when the file is not a 3D model. const SUPPORTED_EXTENSIONS = [ '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', '.dae', '.fbx', '.fcstd', '.glb', '.gltf', @@ -41,6 +47,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin { }, async render(container: HTMLElement, fileUrl: string): Promise { + // TODO: height and/or max-height? const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); const viewer = new OV.EmbeddedViewer(container, { backgroundColor: new OV.RGBAColor(59, 68, 76, 0),