Skip to content

Commit 9f95349

Browse files
committed
feat(file-menu): add plugin architecture for file actions
- Add FileActionExtension for menu/toolbar/both targets - Add ExtensionRegistry service for dynamic extension loading - Add extensions.config.ts for configuration - Add Copy Link as example extension - Add OnlyOffice as example extension (dummy implementation) See docs/file-extensions.md for usage.
1 parent 1f17062 commit 9f95349

28 files changed

+1103
-23
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ testem.log
4444
# System files
4545
.DS_Store
4646
Thumbs.db
47+
48+
# Generated extensions config
49+
src/app/extensions.config.ts

docs/file-extensions.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# File Extensions
2+
3+
Plugin architecture for adding custom actions to the file browser.
4+
5+
## Overview
6+
7+
Extensions are placed in `src/app/extensions/` and managed via `extensions.config.ts`.
8+
9+
## FileActionExtension
10+
11+
```typescript
12+
interface FileActionExtension {
13+
id: string;
14+
label: string;
15+
icon: string;
16+
command: (ctx: FileActionContext) => void;
17+
parentId?: string;
18+
position?: 'start' | 'end' | number;
19+
visible?: (ctx: FileActionContext) => boolean;
20+
disabled?: (ctx: FileActionContext) => boolean;
21+
}
22+
```
23+
24+
### parentId
25+
26+
If `parentId` is set, the extension appears only in that submenu (e.g., `'share'`).
27+
If not set, the extension appears in both menu and toolbar.
28+
29+
### FileActionContext
30+
31+
```typescript
32+
interface FileActionContext {
33+
target: FileModel | FileDetailsModel | FileFolderModel;
34+
location: 'file-list' | 'file-detail';
35+
isViewOnly: boolean;
36+
canWrite: boolean;
37+
}
38+
```
39+
40+
## Example: Submenu Extension
41+
42+
```typescript
43+
export function copyLinksExtensionFactory(): FileActionExtension[] {
44+
return [
45+
{
46+
id: 'copy-link',
47+
label: 'Copy Link',
48+
icon: 'fas fa-link',
49+
command: (ctx) => {
50+
navigator.clipboard.writeText(ctx.target.links.html);
51+
},
52+
parentId: 'share', // appears in Share submenu only
53+
position: 'end',
54+
visible: (ctx) => ctx.target.kind === 'file',
55+
disabled: (ctx) => ctx.isViewOnly,
56+
},
57+
];
58+
}
59+
```
60+
61+
> **Note:** Context menus honor `position` exactly (for example, `'start'` inserts before built-in items). Top-level toolbars append extensions after the core OSF buttons but still keep extension-to-extension ordering based on `position`. Design UX with that constraint in mind.
62+
63+
## Example: Menu + Toolbar Extension
64+
65+
```typescript
66+
export function createFileExtensionFactory(config: Config): FileActionExtension[] {
67+
return [
68+
{
69+
id: 'create-file',
70+
label: 'Create File',
71+
icon: 'fas fa-file-plus',
72+
command: (ctx) => {
73+
window.open(`${config.editorUrl}?path=${ctx.target.path}`, '_blank');
74+
},
75+
// no parentId: appears in both menu and toolbar
76+
position: 'end',
77+
visible: (ctx) =>
78+
ctx.location === 'file-list' &&
79+
ctx.target.kind === 'folder' &&
80+
!ctx.isViewOnly &&
81+
ctx.canWrite,
82+
},
83+
];
84+
}
85+
```
86+
87+
## Configuration
88+
89+
The extension configuration is managed via `extensions.config.ts`, which is generated at build time.
90+
91+
### File Structure
92+
93+
```
94+
src/app/
95+
extensions.config.ts # Generated (not in git)
96+
extensions.config.default.ts # Default config (in git)
97+
```
98+
99+
### Build-time Configuration
100+
101+
By default, `extensions.config.default.ts` is used. To use a custom configuration:
102+
103+
```bash
104+
EXTENSIONS_CONFIG=/path/to/my-extensions.config.ts npm run build
105+
```
106+
107+
The `scripts/setup-extensions.js` helper runs before `npm run build`, `npm start`, and `npm test` (see the `prebuild` / `prestart` / `pretest` npm scripts) and handles this:
108+
- If `EXTENSIONS_CONFIG` is set, copies that file
109+
- Otherwise, copies `extensions.config.default.ts`
110+
111+
## Registration
112+
113+
### extensions.config.ts
114+
115+
```typescript
116+
export const extensionConfig: ExtensionConfig[] = [
117+
{
118+
load: () => import('./extensions/copy-links'),
119+
factory: 'copyLinksExtensionFactory',
120+
enabled: true,
121+
},
122+
{
123+
load: () => import('./extensions/onlyoffice'),
124+
factory: 'editByOnlyOfficeExtensionFactory',
125+
enabled: true,
126+
config: { editorUrl: 'https://...' },
127+
},
128+
{
129+
load: () => import('./extensions/onlyoffice'),
130+
factory: 'createFileExtensionFactory',
131+
enabled: true,
132+
config: { editorUrl: 'https://...' },
133+
},
134+
];
135+
```
136+

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"scripts": {
55
"ng": "ng",
66
"analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks",
7+
"prebuild": "node scripts/setup-extensions.js",
8+
"prestart": "node scripts/setup-extensions.js",
79
"build": "ng build",
810
"check:config": "node ./docker/check-config.js",
911
"ci:test": "jest",
@@ -23,6 +25,7 @@
2325
"start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker",
2426
"start:test": "ng serve --configuration test-osf",
2527
"start:test:future": "ng serve --configuration test",
28+
"pretest": "node scripts/setup-extensions.js",
2629
"test": "jest",
2730
"test:watch": "jest --watch",
2831
"test:coverage": "jest --coverage && npm run test:display",

scripts/setup-extensions.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const targetPath = path.join(__dirname, '../src/app/extensions.config.ts');
5+
const defaultPath = path.join(__dirname, '../src/app/extensions.config.default.ts');
6+
const customPath = process.env.EXTENSIONS_CONFIG;
7+
8+
if (customPath) {
9+
const resolvedPath = path.resolve(customPath);
10+
if (!fs.existsSync(resolvedPath)) {
11+
throw new Error(`EXTENSIONS_CONFIG file not found: ${resolvedPath}`);
12+
}
13+
fs.copyFileSync(resolvedPath, targetPath);
14+
console.log(`Extensions config: ${resolvedPath}`);
15+
} else if (!fs.existsSync(targetPath)) {
16+
fs.copyFileSync(defaultPath, targetPath);
17+
console.log(`Extensions config: ${defaultPath} (default)`);
18+
}

src/app/app.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { authInterceptor } from '@core/interceptors/auth.interceptor';
1717
import { errorInterceptor } from '@core/interceptors/error.interceptor';
1818
import { viewOnlyInterceptor } from '@core/interceptors/view-only.interceptor';
1919
import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/provider/application.initialization.provider';
20+
import { EXTENSION_INITIALIZATION_PROVIDER } from '@core/provider/extension.initialization.provider';
2021
import { SENTRY_PROVIDER } from '@core/provider/sentry.provider';
2122

2223
import CustomPreset from './core/theme/custom-preset';
@@ -53,5 +54,8 @@ export const appConfig: ApplicationConfig = {
5354
provideStore(STATES),
5455
provideZoneChangeDetection({ eventCoalescing: true }),
5556
SENTRY_PROVIDER,
57+
58+
// Dynamic Extension Loading (configured in extensions.config.ts)
59+
EXTENSION_INITIALIZATION_PROVIDER,
5660
],
5761
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { APP_INITIALIZER, Provider } from '@angular/core';
2+
3+
import { ExtensionRegistry } from '@core/services/extension-registry.service';
4+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
5+
6+
import { ExtensionConfig, extensionConfig } from '../../extensions.config';
7+
8+
type FactoryFunction = (config?: unknown) => FileActionExtension[];
9+
10+
async function loadExtensions(registry: ExtensionRegistry): Promise<void> {
11+
for (const ext of extensionConfig) {
12+
if (!ext.enabled) continue;
13+
await loadExtension(ext, registry);
14+
}
15+
}
16+
17+
async function loadExtension(extConfig: ExtensionConfig, registry: ExtensionRegistry): Promise<void> {
18+
const module = await extConfig.load();
19+
const factory = (module as Record<string, FactoryFunction>)[extConfig.factory];
20+
21+
if (typeof factory !== 'function') {
22+
throw new Error(`Extension factory "${extConfig.factory}" not found in module`);
23+
}
24+
25+
const extensions = factory(extConfig.config);
26+
registry.register(extensions);
27+
}
28+
29+
export const EXTENSION_INITIALIZATION_PROVIDER: Provider = {
30+
provide: APP_INITIALIZER,
31+
useFactory: (registry: ExtensionRegistry) => {
32+
return () => loadExtensions(registry);
33+
},
34+
deps: [ExtensionRegistry],
35+
multi: true,
36+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
4+
5+
import { ExtensionRegistry } from './extension-registry.service';
6+
7+
describe('ExtensionRegistry', () => {
8+
let service: ExtensionRegistry;
9+
10+
beforeEach(() => {
11+
TestBed.configureTestingModule({});
12+
service = TestBed.inject(ExtensionRegistry);
13+
});
14+
15+
it('registers extensions cumulatively', () => {
16+
const extA: FileActionExtension = {
17+
id: 'a',
18+
label: 'A',
19+
icon: 'a',
20+
command: jest.fn(),
21+
};
22+
const extB: FileActionExtension = {
23+
id: 'b',
24+
label: 'B',
25+
icon: 'b',
26+
command: jest.fn(),
27+
};
28+
29+
service.register([extA]);
30+
service.register([extB]);
31+
32+
expect(service.extensions()).toEqual([extA, extB]);
33+
});
34+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Injectable, signal } from '@angular/core';
2+
3+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
4+
5+
/**
6+
* Central registry for all plugin extensions.
7+
* Extensions are registered at app initialization time via APP_INITIALIZER.
8+
*/
9+
@Injectable({ providedIn: 'root' })
10+
export class ExtensionRegistry {
11+
private readonly _extensions = signal<FileActionExtension[]>([]);
12+
13+
/** Read-only signal of all extensions */
14+
readonly extensions = this._extensions.asReadonly();
15+
16+
register(extensions: FileActionExtension[]): void {
17+
this._extensions.update((current) => [...current, ...extensions]);
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Extension Configuration
3+
*
4+
* See docs/file-extensions.md for details.
5+
*/
6+
export interface ExtensionConfig {
7+
load: () => Promise<unknown>;
8+
factory: string;
9+
enabled: boolean;
10+
config?: unknown;
11+
}
12+
13+
export const extensionConfig: ExtensionConfig[] = [
14+
// Copy Link - adds "Copy Link" to Share submenu
15+
{
16+
load: () => import('./extensions/copy-links'),
17+
factory: 'copyLinksExtensionFactory',
18+
enabled: true,
19+
},
20+
];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { FileModel } from '@osf/shared/models/files/file.model';
2+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
3+
4+
/**
5+
* Factory function that creates Copy Link action extension.
6+
*/
7+
export function copyLinksExtensionFactory(): FileActionExtension[] {
8+
return [
9+
{
10+
id: 'copy-link',
11+
label: 'Copy Link',
12+
icon: 'fas fa-link',
13+
command: (ctx) => {
14+
const file = ctx.target as FileModel;
15+
navigator.clipboard.writeText(file.links.html);
16+
},
17+
parentId: 'share',
18+
position: 'end',
19+
visible: (ctx) => ctx.target.kind === 'file',
20+
},
21+
];
22+
}

0 commit comments

Comments
 (0)