diff --git a/README.md b/README.md index b5c661e..7f56c0e 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,8 @@ Plugin Playground now exposes command APIs that mirror sidebar data and support - `plugin-playground:list-tokens` - `plugin-playground:list-commands` - `plugin-playground:list-extension-examples` -- `plugin-playground:export-as-extension` (supports optional `{ path: string }`) -- `plugin-playground:share-via-link` (supports optional `{ path: string }`) +- `plugin-playground:export-as-extension` (supports optional `{ path?: string }`) +- `plugin-playground:share-via-link` (supports optional `{ path?: string, useBrowserSelection?: boolean }`) Example: @@ -179,18 +179,25 @@ await app.commands.execute('plugin-playground:export-as-extension', { }); await app.commands.execute('plugin-playground:share-via-link', { - path: 'my-extension/src/index.ts' + path: 'my-extension' }); ``` -`plugin-playground:share-via-link` shares a single file. If no `path` is -provided, it shares the active file. +`plugin-playground:share-via-link` shares a file or folder. If no `path` is +provided, it shares the active file. The file/folder right-click context-menu +entries use the selected browser item path automatically. +Folder sharing skips non-text/media files (for example images and videos). +By default, folder sharing always opens a file-selection dialog so you can +exclude files before creating the link. You can control this with +`shareFolderSelectionDialogMode` (`always`, `auto-excluded-or-limit`, or +`limit-only`). If the generated URL is too long, you can pick a smaller subset +of files in the same dialog. The same action is also available from the `IPluginPlayground` API via `shareViaLink(path?)`. -When opening a shared URL, Plugin Playground restores and opens the shared -file but does not execute it automatically. Run `Load Current File As -Extension` when you are ready. +When opening a shared URL, Plugin Playground restores the shared file(s) and +opens one restored file, but does not execute it automatically. Run `Load +Current File As Extension` when you are ready. List commands (`list-tokens`, `list-commands`, `list-extension-examples`) return a JSON object with: diff --git a/package.json b/package.json index 86a9fa6..30bb366 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@jupyterlab/apputils": "^4.6.5", "@jupyterlab/codemirror": "^4.5.5", "@jupyterlab/completer": "^4.5.5", + "@jupyterlab/filebrowser": "^4.5.5", "@jupyterlab/fileeditor": "^4.5.5", "@jupyterlab/settingregistry": "^4.5.5", "jupyterlab-js-logs": "^1.1.0", @@ -86,7 +87,6 @@ "@jupyterlab/docmanager": "^4.5.5", "@jupyterlab/documentsearch": "^4.5.5", "@jupyterlab/extensionmanager": "^4.5.5", - "@jupyterlab/filebrowser": "^4.5.5", "@jupyterlab/htmlviewer": "^4.5.5", "@jupyterlab/imageviewer": "^4.5.5", "@jupyterlab/inspector": "^4.5.5", diff --git a/schema/plugin.json b/schema/plugin.json index 6a293b3..2caa578 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -44,6 +44,13 @@ "type": "string", "enum": ["insert", "ai"] }, + "shareFolderSelectionDialogMode": { + "title": "Show file selection dialog on folder share", + "description": "Control when folder sharing opens the file-selection dialog. Use \"always\" to always ask, \"auto-excluded-or-limit\" to ask when files are auto-excluded or the URL size limit is hit, or \"limit-only\" to ask only when the URL size limit is hit.", + "default": "always", + "type": "string", + "enum": ["always", "auto-excluded-or-limit", "limit-only"] + }, "plugins": { "title": "Plugins", "description": "List of strings of plugin text to load automatically. Line breaks are encoded as '\\n'", @@ -85,6 +92,22 @@ } ] }, + "jupyter.lab.menus": { + "context": [ + { + "command": "plugin-playground:share-via-link", + "args": { "useBrowserSelection": true }, + "selector": ".jp-DirListing-item[data-isdir=\"false\"]", + "rank": 11 + }, + { + "command": "plugin-playground:share-via-link", + "args": { "useBrowserSelection": true }, + "selector": ".jp-DirListing-item[data-isdir=\"true\"]", + "rank": 12 + } + ] + }, "jupyter.lab.shortcuts": [ { "command": "plugin-playground:load-as-extension", diff --git a/src/index.ts b/src/index.ts index 0da4045..171558f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { Signal } from '@lumino/signaling'; import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; import { FileEditor, IEditorTracker } from '@jupyterlab/fileeditor'; +import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; import { ILauncher } from '@jupyterlab/launcher'; import { IMainMenu } from '@jupyterlab/mainmenu'; @@ -95,6 +96,12 @@ import { import { downloadArchive, IArchiveEntry } from './archive'; import { createTemplateArchive } from './export-template'; import { ShareLink } from './share-link'; +import { + buildFolderSharePayload, + IFolderShareCandidateFile, + selectFolderSharePaths, + shouldSkipFolderShareEntry +} from './share-via-link-utils'; import { Token } from '@lumino/coreutils'; @@ -122,6 +129,11 @@ type PluginLoadStatus = | 'loading-failed' | 'autostart-failed'; +type ShareFolderSelectionDialogMode = + | 'always' + | 'auto-excluded-or-limit' + | 'limit-only'; + interface IPluginLoadResult { status: PluginLoadStatus; ok: boolean; @@ -164,6 +176,19 @@ export interface IPluginShareResult { message?: string; } +type IShareLinkCreationResult = + | { + ok: true; + link: string; + urlLength: number; + } + | { + ok: false; + reason: 'length' | 'payload'; + urlLength: number; + message: string; + }; + const PLUGIN_TEMPLATE = `import { JupyterFrontEnd, JupyterFrontEndPlugin, @@ -240,7 +265,12 @@ const SHARE_VIA_LINK_ARGS_SCHEMA = { path: { type: 'string', description: - 'Optional contents path of the file to share. When omitted, the active editor file is used.' + 'Optional contents path of the file or folder to share. When omitted, the active editor file is used.' + }, + useBrowserSelection: { + type: 'boolean', + description: + 'When true, resolve the share path from the current file browser selection.' } } }; @@ -260,6 +290,8 @@ const LOAD_ON_SAVE_TOGGLE_TOOLBAR_ITEM = 'plugin-playground-load-on-save'; const LOAD_ON_SAVE_CHECKBOX_LABEL = 'Auto Load on Save'; const LOAD_ON_SAVE_SETTING = 'loadOnSave'; const COMMAND_INSERT_DEFAULT_MODE_SETTING = 'commandInsertDefaultMode'; +const SHARE_FOLDER_SELECTION_DIALOG_MODE_SETTING = + 'shareFolderSelectionDialogMode'; const LOAD_ON_SAVE_ENABLED_DESCRIPTION = 'Toggle auto-loading this file as an extension on save'; const LOAD_ON_SAVE_DISABLED_DESCRIPTION = @@ -271,6 +303,8 @@ const JUPYTERLITE_AI_INSTALL_HINT = const JUPYTERLITE_AI_PROVIDER_SETUP_HINT = 'JupyterLite AI provider is not configured. Configure a provider and try again.'; const DEFAULT_COMMAND_INSERT_MODE: CommandInsertMode = 'insert'; +const DEFAULT_SHARE_FOLDER_SELECTION_DIALOG_MODE: ShareFolderSelectionDialogMode = + 'always'; const ARCHIVE_EXCLUDED_DIRECTORIES = new Set([ '.git', '.ipynb_checkpoints', @@ -305,6 +339,7 @@ class PluginPlayground { protected settingRegistry: ISettingRegistry, commandPalette: ICommandPalette, protected editorTracker: IEditorTracker, + protected fileBrowserFactory: IFileBrowserFactory | null, launcher: ILauncher | null, protected documentManager: IDocumentManager | null, protected chatTracker: IChatTracker | null, @@ -382,7 +417,8 @@ class PluginPlayground { app.commands.addCommand(CommandIDs.shareViaLink, { label: 'Copy Shareable Plugin Link', - caption: 'Create a URL for the active plugin file, then copy it', + caption: + 'Create a URL for the active plugin file or selected folder, then copy it', describedBy: { args: SHARE_VIA_LINK_ARGS_SCHEMA }, icon: () => this._copiedCommandId === CommandIDs.shareViaLink @@ -391,7 +427,58 @@ class PluginPlayground { execute: async args => { const requestedPath = typeof args.path === 'string' ? args.path : undefined; - return this.shareViaLink(requestedPath); + const useBrowserSelection = args.useBrowserSelection === true; + if (useBrowserSelection && !requestedPath) { + const contextTarget = this.app.contextMenuHitTest(node => + node.classList.contains('jp-DirListing-item') + ); + const contextTargetName = contextTarget?.querySelector( + '.jp-DirListing-itemText' + )?.textContent; + if ( + contextTargetName !== null && + contextTargetName !== undefined && + contextTargetName !== '..' + ) { + const browserDirectory = ContentUtils.normalizeContentsPath( + this.fileBrowserFactory?.tracker.currentWidget?.model.path ?? '' + ); + const contextTargetPath = ContentUtils.normalizeContentsPath( + browserDirectory + ? PathExt.join(browserDirectory, contextTargetName) + : contextTargetName + ); + if (contextTargetPath) { + return this._shareViaLink(contextTargetPath); + } + } + + const selectedItems = Array.from( + this.fileBrowserFactory?.tracker.currentWidget?.selectedItems() ?? + [] + ).filter(item => item.type === 'file' || item.type === 'directory'); + if (selectedItems.length === 1) { + return this._shareViaLink(selectedItems[0].path); + } + const message = + selectedItems.length === 0 + ? 'No file or folder is selected in the file browser.' + : 'Select a single file or folder in the file browser to share.'; + Notification.warning(message, { + autoClose: 5000 + }); + return { + ok: false, + link: null, + sourcePath: null, + urlLength: 0, + message + }; + } + if (requestedPath) { + return this.shareViaLink(requestedPath); + } + return this.shareViaLink(); } }); @@ -889,7 +976,7 @@ class PluginPlayground { } /** - * Build a share URL for a single file and copy it to clipboard. + * Build a share URL for a file or folder and copy it to clipboard. */ public async shareViaLink(path?: string): Promise { const requestedPath = @@ -914,7 +1001,7 @@ class PluginPlayground { sourcePath: null, urlLength: 0, message: - 'No active editor is available. Pass a path argument to share a specific file.' + 'No active editor is available. Pass a path argument to share a specific file or folder.' }; } @@ -925,13 +1012,14 @@ class PluginPlayground { } /** - * Build a share URL for a single file and copy it to clipboard. + * Build a share URL for a file or folder and copy it to clipboard. */ private async _shareViaLink( sourcePath: string, activeSource?: string ): Promise { const normalizedSourcePath = ContentUtils.normalizeContentsPath(sourcePath); + let sharedSourcePath = normalizedSourcePath; if (!normalizedSourcePath) { return { ok: false, @@ -947,79 +1035,251 @@ class PluginPlayground { this.app.serviceManager, normalizedSourcePath ); + if (directory) { - throw new Error( - 'Folder sharing is temporarily disabled. Pass a file path instead.' + sharedSourcePath = ContentUtils.normalizeContentsPath(directory.path); + const folderShareData = await this._collectShareableFolderFiles( + sharedSourcePath ); + const files = folderShareData.files; + if (files.length === 0) { + throw new Error( + `No text-readable files were found in "${sharedSourcePath}".` + ); + } + + const shouldOpenSelectionDialogByMode = + this._shareFolderSelectionDialogMode === 'always' || + (this._shareFolderSelectionDialogMode === 'auto-excluded-or-limit' && + folderShareData.hasAutoExcludedFiles); + + if (shouldOpenSelectionDialogByMode) { + return this._openFolderShareSelectionDialog( + sharedSourcePath, + files, + this._shareFolderSelectionDialogMode === 'always' && + !folderShareData.hasAutoExcludedFiles + ); + } + + const payload = buildFolderSharePayload(sharedSourcePath, files); + const linkResult = await this._createShareLink(payload); + if (!linkResult.ok) { + this._notifyFolderShareTooLarge( + linkResult.message, + sharedSourcePath, + files + ); + return { + ok: false, + link: null, + sourcePath: sharedSourcePath, + urlLength: linkResult.urlLength, + message: linkResult.message + }; + } + return this._finalizeShareLinkCopy(linkResult.link, sharedSourcePath); } - const source = - activeSource ?? - (await this._readSourceFileForExport(normalizedSourcePath)); - const fileName = this._basename(normalizedSourcePath) || 'plugin.ts'; + const source = + activeSource ?? (await this._readSourceFileForExport(sharedSourcePath)); + const fileName = this._basename(sharedSourcePath) || 'plugin.ts'; const payload: ShareLink.ISharedPluginPayload = { version: 1, + kind: 'file', fileName, source }; - const encodedPayload = await ShareLink.encodeSharedPluginPayload(payload); - const link = ShareLink.createSharedPluginUrl(encodedPayload); - const urlLength = link.length; - - if (urlLength > SHARE_URL_MAX_LENGTH) { + const linkResult = await this._createShareLink(payload); + if (!linkResult.ok) { const message = - `The generated link is ${urlLength} characters long, which exceeds the configured limit ` + - `(${SHARE_URL_MAX_LENGTH}). Share a smaller file or use "Export Plugin Folder As Extension".`; + `${linkResult.message} ` + + `Share a smaller file or use "Export Plugin Folder As Extension".`; Notification.error(message, { autoClose: false }); return { ok: false, link: null, - sourcePath: normalizedSourcePath, - urlLength, + sourcePath: sharedSourcePath, + urlLength: linkResult.urlLength, message }; } - await ContentUtils.copyValueToClipboard(link); - ContentUtils.setCopiedStateWithTimeout( - CommandIDs.shareViaLink, - this._copiedCommandTimer, - timer => { - this._copiedCommandTimer = timer; - }, - copiedCommandId => { - this._copiedCommandId = copiedCommandId; - }, - () => { - this.app.commands.notifyCommandChanged(CommandIDs.shareViaLink); - }, - 1400 - ); - const details = - `Copied a share link for file "${normalizedSourcePath}" ` + - `(${urlLength} characters).`; - - if (urlLength > SHARE_URL_WARN_LENGTH) { - Notification.warning( - `${details} Some browsers may reject very long URLs.`, - { - autoClose: 7000 - } + return this._finalizeShareLinkCopy(linkResult.link, sharedSourcePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + Notification.error(`Plugin share link creation failed: ${message}`, { + autoClose: false + }); + return { + ok: false, + link: null, + sourcePath: sharedSourcePath, + urlLength: 0, + message + }; + } + } + + private async _collectShareableFolderFiles(folderPath: string): Promise<{ + files: IFolderShareCandidateFile[]; + hasAutoExcludedFiles: boolean; + }> { + const filePaths = await this._collectArchiveFilePaths(folderPath); + const textEncoder = new TextEncoder(); + const candidates = await this._mapWithConcurrency( + filePaths, + ARCHIVE_FILE_READ_CONCURRENCY, + async (filePath): Promise => { + const relativePath = this._relativePath(folderPath, filePath); + if (shouldSkipFolderShareEntry(relativePath)) { + return null; + } + + const fileModel = await ContentUtils.getFileModel( + this.app.serviceManager, + filePath ); - } else { - Notification.success(details, { + if (!fileModel || fileModel.format === 'base64') { + return null; + } + + const source = ContentUtils.fileModelToText(fileModel); + if (source === null) { + return null; + } + + return { + relativePath, + source, + sizeBytes: textEncoder.encode(source).length + }; + } + ); + + const files = candidates.filter( + (candidate): candidate is IFolderShareCandidateFile => candidate !== null + ); + + return { + files, + hasAutoExcludedFiles: files.length !== candidates.length + }; + } + + private _notifyFolderShareTooLarge( + message: string, + folderPath: string, + files: ReadonlyArray + ): void { + Notification.error(`${message} Select specific files to continue.`, { + autoClose: false, + actions: [ + { + label: 'Select files', + displayType: 'accent', + callback: () => { + void this._openFolderShareSelectionDialog(folderPath, files, false); + } + } + ] + }); + } + + private async _openFolderShareSelectionDialog( + folderPath: string, + files: ReadonlyArray, + includeDisableDialogCheckbox: boolean + ): Promise { + try { + const selectionResult = await selectFolderSharePaths( + files, + includeDisableDialogCheckbox + ); + if (selectionResult === null) { + return { + ok: false, + link: null, + sourcePath: folderPath, + urlLength: 0, + message: 'Folder share selection was cancelled.' + }; + } + + const selectedPaths = selectionResult.selectedPaths; + if (selectedPaths.length === 0) { + Notification.warning('Select at least one file to share.', { autoClose: 5000 }); + return { + ok: false, + link: null, + sourcePath: folderPath, + urlLength: 0, + message: 'Select at least one file to share.' + }; } - return { - ok: true, - link, - sourcePath: normalizedSourcePath, - urlLength - }; + const selectedPathSet = new Set(selectedPaths); + const selectedFiles = files.filter(file => + selectedPathSet.has(file.relativePath) + ); + const payload = buildFolderSharePayload(folderPath, selectedFiles); + + const linkResult = await this._createShareLink(payload); + if (!linkResult.ok) { + if (linkResult.reason === 'length') { + const message = + `The selected files still produce a ${linkResult.urlLength}-character link ` + + `(limit: ${SHARE_URL_MAX_LENGTH}). Select fewer files.`; + Notification.error(message, { autoClose: false }); + return { + ok: false, + link: null, + sourcePath: folderPath, + urlLength: linkResult.urlLength, + message + }; + } + Notification.error(`${linkResult.message} Select fewer files.`, { + autoClose: false + }); + return { + ok: false, + link: null, + sourcePath: folderPath, + urlLength: linkResult.urlLength, + message: `${linkResult.message} Select fewer files.` + }; + } + + const result = await this._finalizeShareLinkCopy( + linkResult.link, + folderPath + ); + const allFilesSelected = selectedPaths.length === files.length; + if ( + includeDisableDialogCheckbox && + allFilesSelected && + selectionResult.disableDialogIfAllFilesCanBeIncluded + ) { + try { + await this.settings.set( + SHARE_FOLDER_SELECTION_DIALOG_MODE_SETTING, + 'auto-excluded-or-limit' + ); + this._shareFolderSelectionDialogMode = 'auto-excluded-or-limit'; + } catch (error) { + console.warn( + `Failed to persist "${SHARE_FOLDER_SELECTION_DIALOG_MODE_SETTING}" setting.`, + error + ); + } + } + + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); Notification.error(`Plugin share link creation failed: ${message}`, { @@ -1028,16 +1288,94 @@ class PluginPlayground { return { ok: false, link: null, - sourcePath: normalizedSourcePath, + sourcePath: folderPath, urlLength: 0, message }; } } + private async _createShareLink( + payload: ShareLink.ISharedPluginPayload + ): Promise { + try { + const encodedPayload = await ShareLink.encodeSharedPluginPayload(payload); + const link = ShareLink.createSharedPluginUrl(encodedPayload); + const urlLength = link.length; + if (urlLength > SHARE_URL_MAX_LENGTH) { + return { + ok: false, + reason: 'length', + urlLength, + message: + `The generated link is ${urlLength} characters long, which exceeds the configured limit ` + + `(${SHARE_URL_MAX_LENGTH}).` + }; + } + return { + ok: true, + link, + urlLength + }; + } catch (error) { + if (ShareLink.isSharedPayloadTooLargeError(error)) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + reason: 'payload', + urlLength: 0, + message + }; + } + throw error; + } + } + + private async _finalizeShareLinkCopy( + link: string, + sourcePath: string + ): Promise { + const urlLength = link.length; + await ContentUtils.copyValueToClipboard(link); + ContentUtils.setCopiedStateWithTimeout( + CommandIDs.shareViaLink, + this._copiedCommandTimer, + timer => { + this._copiedCommandTimer = timer; + }, + copiedCommandId => { + this._copiedCommandId = copiedCommandId; + }, + () => { + this.app.commands.notifyCommandChanged(CommandIDs.shareViaLink); + }, + 1400 + ); + const details = `Copied a share link for "${sourcePath}" (${urlLength} characters).`; + + if (urlLength > SHARE_URL_WARN_LENGTH) { + Notification.warning( + `${details} Some browsers may reject very long URLs.`, + { + autoClose: 7000 + } + ); + } else { + Notification.success(details, { + autoClose: 5000 + }); + } + return { + ok: true, + link, + sourcePath, + urlLength + }; + } + /** - * Restore a shared file from URL token into a workspace folder and open it. - * The file is not executed automatically. + * Restore shared file(s) from URL token into a workspace folder and open one. + * Files are not executed automatically. */ private async _loadSharedPluginFromUrl(): Promise { const sharedToken = ShareLink.getSharedPluginTokenFromLocation(); @@ -1050,107 +1388,148 @@ class PluginPlayground { try { const payload = await ShareLink.decodeSharedPluginPayload(sharedToken); - const fileName = this._basename(payload.fileName) || 'plugin.ts'; - const extension = PathExt.extname(fileName); - const rootName = extension - ? fileName.slice(0, -extension.length) - : fileName; - const rootFolder = ShareLink.sharedPluginFolderName( + const sharedEntries = + payload.kind === 'folder' + ? Object.entries(payload.files).map(([relativePath, source]) => ({ + relativePath, + source + })) + : [ + { + relativePath: this._basename(payload.fileName) || 'plugin.ts', + source: payload.source + } + ]; + if (sharedEntries.length === 0) { + throw new Error('Shared payload does not include any files.'); + } + + const rootName = (() => { + if (payload.kind === 'folder') { + return this._basename(payload.rootName) || 'shared-plugin'; + } + const fileName = this._basename(payload.fileName) || 'plugin.ts'; + const extension = PathExt.extname(fileName); + return extension ? fileName.slice(0, -extension.length) : fileName; + })(); + const baseRootFolder = ShareLink.sharedPluginFolderName( rootName, sharedToken ); - const rootPath = ContentUtils.normalizeContentsPath( - this._joinPath(SHARED_LINKS_ROOT, rootFolder) - ); - await ContentUtils.ensureContentsDirectory( - this.app.serviceManager, - rootPath - ); - - const baseName = extension - ? fileName.slice(0, -extension.length) - : fileName; - let entryPath = ''; - let shouldWrite = false; - const maxVariants = 1000; + const maxVariants = 100; + let rootPath = ''; for (let variant = 1; variant <= maxVariants; variant++) { - const candidateName = - variant === 1 ? fileName : `${baseName}-${variant}${extension}`; - const candidatePath = ContentUtils.normalizeContentsPath( - this._joinPath(rootPath, candidateName) + const folderName = + variant === 1 ? baseRootFolder : `${baseRootFolder}-${variant}`; + const candidateRootPath = ContentUtils.normalizeContentsPath( + this._joinPath(SHARED_LINKS_ROOT, folderName) ); - const existingFile = await ContentUtils.getFileModel( + await ContentUtils.ensureContentsDirectory( this.app.serviceManager, - candidatePath + candidateRootPath ); - if (!existingFile) { - entryPath = candidatePath; - shouldWrite = true; - break; + let isCompatible = true; + for (const entry of sharedEntries) { + const candidatePath = ContentUtils.normalizeContentsPath( + this._joinPath(candidateRootPath, entry.relativePath) + ); + const existingFile = await ContentUtils.getFileModel( + this.app.serviceManager, + candidatePath + ); + if (!existingFile) { + continue; + } + const existingSource = ContentUtils.fileModelToText(existingFile); + if (existingSource !== entry.source) { + isCompatible = false; + break; + } } - - const existingSource = ContentUtils.fileModelToText(existingFile); - if (existingSource === payload.source) { - entryPath = candidatePath; - shouldWrite = false; + if (isCompatible) { + rootPath = candidateRootPath; break; } } - if (!entryPath) { + if (!rootPath) { throw new Error( - `Could not find a writable location for shared file "${fileName}" in "${rootPath}".` + `Could not find a writable location for shared files under "${SHARED_LINKS_ROOT}/${baseRootFolder}".` ); } - let restoredPath = entryPath; - if (shouldWrite) { - const saved = await this.app.serviceManager.contents.save(entryPath, { - type: 'file', - format: 'text', - content: payload.source - }); - if (!saved || saved.type !== 'file') { - throw new Error( - `Failed to save shared file "${fileName}" at "${entryPath}".` + + const restoredPaths: string[] = []; + for (const entry of sharedEntries) { + const entryPath = ContentUtils.normalizeContentsPath( + this._joinPath(rootPath, entry.relativePath) + ); + const entryDirectory = this._dirname(entryPath); + if (entryDirectory) { + await ContentUtils.ensureContentsDirectory( + this.app.serviceManager, + entryDirectory ); } - const normalizedSavedPath = ContentUtils.normalizeContentsPath( - saved.path + + const existingFile = await ContentUtils.getFileModel( + this.app.serviceManager, + entryPath ); - if (normalizedSavedPath !== entryPath) { - throw new Error( - `Shared file was saved to unexpected path "${normalizedSavedPath}" instead of "${entryPath}".` + const existingSource = ContentUtils.fileModelToText(existingFile); + if (existingSource !== entry.source) { + const saved = await this.app.serviceManager.contents.save(entryPath, { + type: 'file', + format: 'text', + content: entry.source + }); + if (!saved || saved.type !== 'file') { + throw new Error(`Failed to save shared file at "${entryPath}".`); + } + const normalizedSavedPath = ContentUtils.normalizeContentsPath( + saved.path ); + if (normalizedSavedPath !== entryPath) { + throw new Error( + `Shared file was saved to unexpected path "${normalizedSavedPath}" instead of "${entryPath}".` + ); + } } - } - let restoredFile = await ContentUtils.getFileModel( - this.app.serviceManager, - entryPath - ); - for (let attempt = 0; !restoredFile && attempt < 8; attempt++) { - await new Promise(resolve => { - window.setTimeout(resolve, 75); - }); - restoredFile = await ContentUtils.getFileModel( + + let restoredFile = await ContentUtils.getFileModel( this.app.serviceManager, entryPath ); - } - if (!restoredFile) { - throw new Error( - `Shared file "${entryPath}" could not be found after restore.` + for (let attempt = 0; !restoredFile && attempt < 8; attempt++) { + await new Promise(resolve => { + window.setTimeout(resolve, 75); + }); + restoredFile = await ContentUtils.getFileModel( + this.app.serviceManager, + entryPath + ); + } + if (!restoredFile) { + throw new Error( + `Shared file "${entryPath}" could not be found after restore.` + ); + } + restoredPaths.push( + ContentUtils.normalizeContentsPath(restoredFile.path) ); } - restoredPath = ContentUtils.normalizeContentsPath(restoredFile.path); + const openedPath = restoredPaths[0]; await this.app.commands.execute('docmanager:open', { - path: restoredPath, + path: openedPath, factory: 'Editor' }); + const fileCount = restoredPaths.length; + const openedLocation = fileCount === 1 ? openedPath : rootPath; Notification.success( - `Opened shared plugin from URL at "${restoredPath}" (1 file). `, + `Opened shared plugin from URL at "${openedLocation}" ` + + `(${fileCount} file${fileCount === 1 ? '' : 's'}).`, { autoClose: 6000 } @@ -1164,6 +1543,14 @@ class PluginPlayground { } private async _readSourceFileForExport(path: string): Promise { + const source = await ContentUtils.readContentsFileAsText( + this.app.serviceManager, + path + ); + if (source !== null) { + return source; + } + const fileModel = await ContentUtils.getFileModel( this.app.serviceManager, path @@ -1171,13 +1558,10 @@ class PluginPlayground { if (!fileModel) { throw new Error(`Could not read file "${path}".`); } - const source = ContentUtils.fileModelToText(fileModel); - if (source === null) { - throw new Error( - `Could not export file "${path}" because it is not readable as text.` - ); - } - return source; + + throw new Error( + `Could not export file "${path}" because it is not readable as text.` + ); } private async _resolveExportContext( @@ -1285,6 +1669,55 @@ class PluginPlayground { archiveEntries: IArchiveEntry[], overrides: ReadonlyMap ): Promise { + const { nestedDirectories, filePaths } = + await this._collectArchiveDirectoryPaths(directoryPath); + + for (const nestedDirectory of nestedDirectories) { + await this._collectArchiveEntriesInDirectory( + rootPath, + nestedDirectory, + archiveEntries, + overrides + ); + } + + const fileEntries = await this._mapWithConcurrency( + filePaths, + ARCHIVE_FILE_READ_CONCURRENCY, + async filePath => + this._createArchiveEntryForFile(rootPath, filePath, overrides) + ); + for (const entry of fileEntries) { + if (entry) { + archiveEntries.push(entry); + } + } + } + + private async _collectArchiveFilePaths(rootPath: string): Promise { + const normalizedRootPath = ContentUtils.normalizeContentsPath(rootPath); + const filePaths: string[] = []; + const pendingDirectories = [normalizedRootPath]; + + while (pendingDirectories.length > 0) { + const directoryPath = pendingDirectories.pop(); + if (!directoryPath) { + continue; + } + + const { nestedDirectories, filePaths: directFilePaths } = + await this._collectArchiveDirectoryPaths(directoryPath); + filePaths.push(...directFilePaths); + pendingDirectories.push(...nestedDirectories); + } + + return filePaths; + } + + private async _collectArchiveDirectoryPaths(directoryPath: string): Promise<{ + nestedDirectories: string[]; + filePaths: string[]; + }> { const directory = await ContentUtils.getDirectoryModel( this.app.serviceManager, directoryPath @@ -1319,26 +1752,7 @@ class PluginPlayground { } } - for (const nestedDirectory of nestedDirectories) { - await this._collectArchiveEntriesInDirectory( - rootPath, - nestedDirectory, - archiveEntries, - overrides - ); - } - - const fileEntries = await this._mapWithConcurrency( - filePaths, - ARCHIVE_FILE_READ_CONCURRENCY, - async filePath => - this._createArchiveEntryForFile(rootPath, filePath, overrides) - ); - for (const entry of fileEntries) { - if (entry) { - archiveEntries.push(entry); - } - } + return { nestedDirectories, filePaths }; } private async _createArchiveEntryForFile( @@ -1384,10 +1798,17 @@ class PluginPlayground { if (!normalizedRootPath) { return normalizedPath; } - if (normalizedPath.startsWith(`${normalizedRootPath}/`)) { - return normalizedPath.slice(normalizedRootPath.length + 1); + + const normalizedRelativePath = ContentUtils.normalizeContentsPath( + PathExt.relative(normalizedRootPath, normalizedPath) + ); + if ( + normalizedRelativePath === '..' || + normalizedRelativePath.startsWith('../') + ) { + return normalizedPath; } - return normalizedPath; + return normalizedRelativePath; } private _dirname(path: string): string { @@ -1395,11 +1816,7 @@ class PluginPlayground { /\/+$/g, '' ); - const index = normalizedPath.lastIndexOf('/'); - if (index <= 0) { - return ''; - } - return normalizedPath.slice(0, index); + return PathExt.dirname(normalizedPath); } private _basename(path: string): string { @@ -1407,14 +1824,7 @@ class PluginPlayground { /\/+$/g, '' ); - if (!normalizedPath) { - return ''; - } - const index = normalizedPath.lastIndexOf('/'); - if (index === -1) { - return normalizedPath; - } - return normalizedPath.slice(index + 1); + return PathExt.basename(normalizedPath); } private async _findExtensionRoot( @@ -1425,9 +1835,7 @@ class PluginPlayground { '' ); while (true) { - const packageJsonPath = current - ? `${current}/package.json` - : 'package.json'; + const packageJsonPath = this._joinPath(current, 'package.json'); const packageJson = await ContentUtils.getFileModel( this.app.serviceManager, packageJsonPath @@ -1490,6 +1898,16 @@ class PluginPlayground { ); this._commandInsertMode = rawCommandInsertMode === 'ai' ? 'ai' : DEFAULT_COMMAND_INSERT_MODE; + + const rawShareFolderSelectionDialogMode = this._stringValue( + composite[SHARE_FOLDER_SELECTION_DIALOG_MODE_SETTING] + ); + this._shareFolderSelectionDialogMode = + rawShareFolderSelectionDialogMode === 'always' || + rawShareFolderSelectionDialogMode === 'auto-excluded-or-limit' || + rawShareFolderSelectionDialogMode === 'limit-only' + ? rawShareFolderSelectionDialogMode + : DEFAULT_SHARE_FOLDER_SELECTION_DIALOG_MODE; } private _getTokenRecords(): ReadonlyArray { @@ -1967,12 +2385,12 @@ class PluginPlayground { } private _joinPath(base: string, child: string): string { - const normalizedBase = base.replace(/\/+$/g, ''); + const normalizedBase = ContentUtils.normalizeContentsPath(base).replace( + /\/+$/g, + '' + ); const normalizedChild = ContentUtils.normalizeContentsPath(child); - if (!normalizedBase) { - return normalizedChild; - } - return `${normalizedBase}/${normalizedChild}`; + return PathExt.join(normalizedBase, normalizedChild); } private _parseJsonObject( @@ -2749,6 +3167,8 @@ class PluginPlayground { MainAreaWidget