This commit is contained in:
Kerwin Bryant 2025-06-22 17:40:54 -07:00 committed by GitHub
commit c65390d764
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 339 additions and 4 deletions

51
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -110,7 +110,7 @@
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
<div class="file-view-container" data-global-init="initFileView" data-filename="{{.TreePath}}" data-url="{{$.RawFileLink}}" data-fallback-text="{{ctx.Locale.Tr "repo.file_view_raw"}}"></div>
{{end}}
</div>
{{else if .FileSize}}

View File

@ -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

71
web_src/css/file-view.css Normal file
View File

@ -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;
}

View File

@ -85,4 +85,6 @@
@import "./helpers.css";
@import "./file-view.css";
@tailwind utilities;

View File

@ -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 = `
<div class="view-raw-fallback">
<a href="${fileUrl}" class="ui basic button" target="_blank">${fallbackText}</a>
</div>
`;
}
} catch (error) {
console.error('file view init error:', error);
// show error message
const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File';
container.innerHTML = `
<div class="ui error message">
<div class="header">Failed to render file</div>
<p>Error: ${String(error)}</p>
<pre>${JSON.stringify({filename, fileUrl}, null, 2)}</pre>
<a class="ui basic button" href="${fileUrl || '#'}" target="_blank">${fallbackText}</a>
</div>
`;
} finally {
// remove loading state
container.classList.remove('is-loading');
}
});
}

View File

@ -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.

View File

@ -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<void>;
}
// 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<boolean> {
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;
}
}

View File

@ -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<void> {
// 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 = `
<div class="ui error message">
<div class="header">Failed to render 3D model</div>
<p>The 3D model could not be displayed in the browser.</p>
<a class="ui basic button" href="${fileUrl}" target="_blank">${fallbackText}</a>
</div>
`;
} finally {
// remove loading state
container.classList.remove('is-loading');
}
},
};
// register plugin
registerFileRenderPlugin(plugin);
}