Skip to content

Commit 6161711

Browse files
authored
Add one-click extension export (jupyterlab#123)
* Add one-click extension export * remove base64 encoding in URL and keep minimal files in template * add suggestions * add suggestions
1 parent 5130cfa commit 6161711

9 files changed

Lines changed: 897 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,18 @@ Plugin Playground now exposes command APIs that mirror sidebar data and support
143143
- `plugin-playground:list-tokens`
144144
- `plugin-playground:list-commands`
145145
- `plugin-playground:list-extension-examples`
146+
- `plugin-playground:export-as-extension` (supports optional `{ path: string }`)
146147

147148
Example:
148149

149150
```typescript
150151
await app.commands.execute('plugin-playground:list-tokens', {
151152
query: 'notebook'
152153
});
154+
155+
await app.commands.execute('plugin-playground:export-as-extension', {
156+
path: 'my-extension/src/index.ts'
157+
});
153158
```
154159

155160
Each command returns a JSON object with:

_agents/skills/plugin-authoring/SKILL.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,14 @@ Produce working plugin code that can be loaded with `plugin-playground:load-as-e
6464
- Check the command return value for `ok/status/message` to detect and report loading or autostart errors.
6565
- If reloading the same plugin ID repeatedly, ensure cleanup is handled via `deactivate()` where needed.
6666

67-
6. Imports and module safety
67+
6. Export for standalone development
68+
69+
- Run `plugin-playground:export-as-extension` to download a zip for local IDE + git workflows.
70+
- For deterministic automation (or when another file is focused), pass an explicit file path:
71+
- `app.commands.execute('plugin-playground:export-as-extension', { path: 'my-extension/src/index.ts' })`
72+
- Read export result metadata (`ok`, `archiveName`, `rootPath`, `fileCount`, `message`) and report failures.
73+
74+
7. Imports and module safety
6875

6976
- Prefer JupyterLab/Lumino imports first.
7077
- For external packages, ensure AMD-compatible import targets are used.

schema/plugin.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@
6363
"command": "plugin-playground:load-as-extension",
6464
"rank": 20
6565
},
66-
{ "name": "plugin-playground-load-on-save", "rank": 21 }
66+
{ "name": "plugin-playground-load-on-save", "rank": 21 },
67+
{
68+
"name": "export-extension",
69+
"command": "plugin-playground:export-as-extension",
70+
"label": "Export",
71+
"rank": 22
72+
}
6773
]
6874
},
6975
"jupyter.lab.shortcuts": [

src/archive.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { normalizeContentsPath } from './contents';
2+
3+
export interface IArchiveEntry {
4+
path: string;
5+
data: Uint8Array;
6+
}
7+
8+
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
9+
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50;
10+
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
11+
const UTF8_FILENAME_FLAG = 0x0800;
12+
const ZIP_VERSION = 20;
13+
14+
const ZIP_MIME_TYPE = 'application/zip';
15+
16+
const CRC32_TABLE = (() => {
17+
const table = new Uint32Array(256);
18+
for (let index = 0; index < table.length; index++) {
19+
let value = index;
20+
for (let bit = 0; bit < 8; bit++) {
21+
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
22+
}
23+
table[index] = value >>> 0;
24+
}
25+
return table;
26+
})();
27+
28+
function createBuffer(size: number): { bytes: Uint8Array; view: DataView } {
29+
const bytes = new Uint8Array(size);
30+
return { bytes, view: new DataView(bytes.buffer) };
31+
}
32+
33+
function crc32(data: Uint8Array): number {
34+
let crc = 0xffffffff;
35+
for (const value of data) {
36+
crc = CRC32_TABLE[(crc ^ value) & 0xff] ^ (crc >>> 8);
37+
}
38+
return (crc ^ 0xffffffff) >>> 0;
39+
}
40+
41+
function createZipBytes(entries: ReadonlyArray<IArchiveEntry>): Uint8Array {
42+
const encoder = new TextEncoder();
43+
const localChunks: Uint8Array[] = [];
44+
const centralChunks: Uint8Array[] = [];
45+
let localOffset = 0;
46+
let entryCount = 0;
47+
48+
for (const entry of entries) {
49+
const normalizedPath = normalizeContentsPath(
50+
entry.path.replace(/\\/g, '/')
51+
);
52+
if (!normalizedPath) {
53+
continue;
54+
}
55+
56+
const pathBytes = encoder.encode(normalizedPath);
57+
const data = entry.data;
58+
const dataLength = data.length;
59+
const entryCrc32 = crc32(data);
60+
61+
const { bytes: localHeader, view: localView } = createBuffer(
62+
30 + pathBytes.length
63+
);
64+
localView.setUint32(0, LOCAL_FILE_HEADER_SIGNATURE, true);
65+
localView.setUint16(4, ZIP_VERSION, true);
66+
localView.setUint16(6, UTF8_FILENAME_FLAG, true);
67+
localView.setUint16(8, 0, true);
68+
localView.setUint16(10, 0, true);
69+
localView.setUint16(12, 0, true);
70+
localView.setUint32(14, entryCrc32, true);
71+
localView.setUint32(18, dataLength, true);
72+
localView.setUint32(22, dataLength, true);
73+
localView.setUint16(26, pathBytes.length, true);
74+
localView.setUint16(28, 0, true);
75+
localHeader.set(pathBytes, 30);
76+
localChunks.push(localHeader, data);
77+
78+
const { bytes: centralHeader, view: centralView } = createBuffer(
79+
46 + pathBytes.length
80+
);
81+
centralView.setUint32(0, CENTRAL_DIRECTORY_HEADER_SIGNATURE, true);
82+
centralView.setUint16(4, ZIP_VERSION, true);
83+
centralView.setUint16(6, ZIP_VERSION, true);
84+
centralView.setUint16(8, UTF8_FILENAME_FLAG, true);
85+
centralView.setUint16(10, 0, true);
86+
centralView.setUint16(12, 0, true);
87+
centralView.setUint16(14, 0, true);
88+
centralView.setUint32(16, entryCrc32, true);
89+
centralView.setUint32(20, dataLength, true);
90+
centralView.setUint32(24, dataLength, true);
91+
centralView.setUint16(28, pathBytes.length, true);
92+
centralView.setUint16(30, 0, true);
93+
centralView.setUint16(32, 0, true);
94+
centralView.setUint16(34, 0, true);
95+
centralView.setUint16(36, 0, true);
96+
centralView.setUint32(38, 0, true);
97+
centralView.setUint32(42, localOffset, true);
98+
centralHeader.set(pathBytes, 46);
99+
centralChunks.push(centralHeader);
100+
101+
localOffset += localHeader.length + dataLength;
102+
if (localOffset > 0xffffffff) {
103+
throw new Error('Export is too large for ZIP32 archives.');
104+
}
105+
entryCount += 1;
106+
if (entryCount > 0xffff) {
107+
throw new Error('Too many files to export in a ZIP32 archive.');
108+
}
109+
}
110+
111+
const centralDirectory = new Uint8Array(
112+
centralChunks.reduce((sum, chunk) => sum + chunk.length, 0)
113+
);
114+
let centralCursor = 0;
115+
for (const chunk of centralChunks) {
116+
centralDirectory.set(chunk, centralCursor);
117+
centralCursor += chunk.length;
118+
}
119+
120+
const { bytes: endOfCentralDirectory, view: eocdView } = createBuffer(22);
121+
eocdView.setUint32(0, END_OF_CENTRAL_DIRECTORY_SIGNATURE, true);
122+
eocdView.setUint16(4, 0, true);
123+
eocdView.setUint16(6, 0, true);
124+
eocdView.setUint16(8, entryCount, true);
125+
eocdView.setUint16(10, entryCount, true);
126+
eocdView.setUint32(12, centralDirectory.length, true);
127+
eocdView.setUint32(16, localOffset, true);
128+
eocdView.setUint16(20, 0, true);
129+
130+
const totalSize =
131+
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) +
132+
centralDirectory.length +
133+
endOfCentralDirectory.length;
134+
const archive = new Uint8Array(totalSize);
135+
let offset = 0;
136+
for (const chunk of localChunks) {
137+
archive.set(chunk, offset);
138+
offset += chunk.length;
139+
}
140+
archive.set(centralDirectory, offset);
141+
offset += centralDirectory.length;
142+
archive.set(endOfCentralDirectory, offset);
143+
return archive;
144+
}
145+
146+
function triggerDownload(href: string, filename: string): void {
147+
const link = document.createElement('a');
148+
link.href = href;
149+
link.download = filename;
150+
link.rel = 'noopener';
151+
link.style.display = 'none';
152+
document.body.appendChild(link);
153+
link.click();
154+
link.remove();
155+
}
156+
157+
export function downloadArchive(
158+
entries: ReadonlyArray<IArchiveEntry>,
159+
filename: string
160+
): void {
161+
const zipped = createZipBytes(entries);
162+
const blob = new Blob([zipped], { type: ZIP_MIME_TYPE });
163+
const objectUrl = URL.createObjectURL(blob);
164+
triggerDownload(objectUrl, filename);
165+
window.setTimeout(() => {
166+
URL.revokeObjectURL(objectUrl);
167+
}, 1000);
168+
}

src/contents.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function contentsPathCandidates(path: string): string[] {
7979
// paths should be rooted. Try both forms so callers can use one code path.
8080
const trimmed = normalizeContentsPath(path);
8181
if (trimmed.length === 0) {
82-
return [];
82+
return ['', '/'];
8383
}
8484
return [trimmed, `/${trimmed}`];
8585
}
@@ -165,6 +165,32 @@ export function fileModelToText(fileModel: IFileModel | null): string | null {
165165
return null;
166166
}
167167

168+
export function fileModelToBytes(
169+
fileModel: IFileModel | null
170+
): Uint8Array | null {
171+
if (!fileModel) {
172+
return null;
173+
}
174+
175+
if (typeof fileModel.content === 'string') {
176+
if (fileModel.format === 'base64') {
177+
try {
178+
const decoded = atob(fileModel.content);
179+
const bytes = new Uint8Array(decoded.length);
180+
for (let index = 0; index < decoded.length; index++) {
181+
bytes[index] = decoded.charCodeAt(index);
182+
}
183+
return bytes;
184+
} catch {
185+
return null;
186+
}
187+
}
188+
}
189+
190+
const text = fileModelToText(fileModel);
191+
return text === null ? null : new TextEncoder().encode(text);
192+
}
193+
168194
export async function readContentsFileAsText(
169195
serviceManager: ServiceManager.IManager,
170196
path: string

0 commit comments

Comments
 (0)