diff --git a/package.json b/package.json index cc02a6c40..e8e0f6c38 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,11 @@ }, "contributes": { "commands": [ + { + "command": "runme.workspaceInit", + "category": "Runme", + "title": "Attach Workspace" + }, { "command": "runme.new", "title": "Runme Notebook" @@ -1155,6 +1160,12 @@ }, "views": { "explorer": [ + { + "id": "runme.workspaceNotebooks", + "type": "tree", + "name": "Workspace Notebooks (tree)", + "visibility": "visible" + }, { "id": "runme.launcher", "type": "tree", @@ -1163,12 +1174,6 @@ } ], "runme": [ - { - "id": "runme.workspaceNotebooks", - "type": "tree", - "name": "Workspace Notebooks", - "visibility": "visible" - }, { "id": "runme.cloud", "type": "webview", diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 0e14c230c..31a534394 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -100,6 +100,13 @@ export class RunmeExtension { protected serializer?: SerializerBase async initialize(context: ExtensionContext) { + // Register the Runme file system provider as soon as possible + context.subscriptions.push( + workspace.registerFileSystemProvider('runmefs', new WorkspaceNotebooksFileSystem(), { + isReadonly: false, + }), + ) + const kernel = new Kernel(context) const grpcSerializer = kernel.hasExperimentEnabled('grpcSerializer') const grpcServer = kernel.hasExperimentEnabled('grpcServer') @@ -431,15 +438,15 @@ export class RunmeExtension { ) }, ), - + commands.registerCommand('runme.workspaceInit', (_) => { + workspace.updateWorkspaceFolders(0, 0, { + uri: Uri.parse('runmefs://foo.com'), + name: 'Workspace Notebooks', + }) + }), RunmeExtension.registerCommand('runme.openCloudPanel', () => commands.executeCommand('workbench.view.extension.runme'), ), - - workspace.registerFileSystemProvider('runmefs', new WorkspaceNotebooksFileSystem(), { - isReadonly: false, - }), - // Register a command to generate completions using foyle RunmeExtension.registerCommand( 'runme.aiGenerate', diff --git a/src/extension/messages/platformRequest/getAllWorkflows.ts b/src/extension/messages/platformRequest/getAllWorkflows.ts index 156b198ce..37fa805dc 100644 --- a/src/extension/messages/platformRequest/getAllWorkflows.ts +++ b/src/extension/messages/platformRequest/getAllWorkflows.ts @@ -7,6 +7,7 @@ export default async function getAllWorkflows() { const result = await graphClient.query({ query: GetAllWorkflowsDocument, variables: { + fileName: 'vscode-runme', page: 1, }, }) diff --git a/src/extension/provider/workspaceNotebooks.ts b/src/extension/provider/workspaceNotebooks.ts index ed50987b3..eabee0f0e 100644 --- a/src/extension/provider/workspaceNotebooks.ts +++ b/src/extension/provider/workspaceNotebooks.ts @@ -18,10 +18,9 @@ export class WorkspaceNotebooks implements TreeDataProvider, return Promise.resolve([]) } - const items = workflows.map((workflow) => { - const uri = Uri.parse( - `runmefs://github.com/${workflow.repository}/blob/${workflow.path}?id=${workflow.id}`, - ) + const items = workflows.reduce((acc: { [key: string]: TreeItem[] }, workflow) => { + const [_owner, repository] = workflow.repository.split('/') + const uri = Uri.parse(`runmefs://foo.com/${repository}/${workflow.path}?q=${workflow.id}`) const item: TreeItem = { label: `${workflow.path}`, @@ -36,10 +35,27 @@ export class WorkspaceNotebooks implements TreeDataProvider, }, } - return item - }) + const path = workflow.path + acc[path] = acc[path] || [] + acc[path].push(item) + return acc + }, {}) - return items + const sortedItems = Object.keys(items) + .sort((a, b) => { + const aHasSlash = a.includes('/') + const bHasSlash = b.includes('/') + if (aHasSlash && !bHasSlash) { + return -1 + } + if (!aHasSlash && bHasSlash) { + return 1 + } + return a.localeCompare(b) + }) + .flatMap((path) => items[path]) + + return sortedItems } dispose(): void { diff --git a/src/extension/provider/workspaceNotebooksFileSystem.ts b/src/extension/provider/workspaceNotebooksFileSystem.ts index f6928bfce..6e94b7b3c 100644 --- a/src/extension/provider/workspaceNotebooksFileSystem.ts +++ b/src/extension/provider/workspaceNotebooksFileSystem.ts @@ -11,14 +11,38 @@ import { } from 'vscode' import getOneWorkflow from '../messages/platformRequest/getOneWorkflow' +import getAllWorkflows from '../messages/platformRequest/getAllWorkflows' +export type Workflow = { + owner: string + repository: string + path: string + id: string +} + +export type TreeNode = { + path: string + fileType?: FileType + children?: TreeNode[] +} + +export type TreeNodes = TreeNode[] +export type NodesMap = Record + +/** + * Handles the virtual file system runmefs:// + */ export default class WorkspaceNotebooksFileSystem implements FileSystemProvider { private _onDidChangeFile: EventEmitter = new EventEmitter() readonly onDidChangeFile: Event = this._onDidChangeFile.event - async readFile(uri: Uri): Promise { - // extraxt id from query string in uri - const id = uri.query.split('=')[1] + #notebooks: Workflow[] = [] + #nodesMap: NodesMap = {} + + async readFile(sourceUri: Uri): Promise { + const uri = sourceUri.with({ authority: 'foo.com' }) + let id: string | undefined = uri.query.split('=')[1] + id = id || this.#notebooks.find((n) => `/${n.path}` === uri.path)?.id if (!id) { throw FileSystemError.FileNotFound(uri) @@ -48,7 +72,40 @@ export default class WorkspaceNotebooksFileSystem implements FileSystemProvider return new Disposable(() => {}) } - async stat(uri: Uri): Promise { + isFile(pathname: string): boolean { + const parts = pathname.split('/') + const lastPart = parts[parts.length - 1] + const hasExtension = lastPart && /\.[^/.]+$/.test(lastPart) + return Boolean(hasExtension) + } + + isDir(pathname: string): boolean { + return !this.isFile(pathname) + } + + async stat(sourceUri: Uri): Promise { + const uri = sourceUri.with({ authority: 'foo.com' }) + const excludedPaths = [ + '.vscode/tasks.json', + '.vscode/launch.json', + '.vscode/settings.json', + '.runme_bootstrap', + '.runme_bootstrap_demo', + ] + + if (excludedPaths.includes(uri.path)) { + throw FileSystemError.FileNotFound(uri) + } + + if (this.#nodesMap[uri.path] || this.isDir(uri.path)) { + return { + type: FileType.Directory, + ctime: Date.now(), + mtime: Date.now(), + size: 0, + } + } + return { type: FileType.File, size: (await this.readFile(uri)).byteLength, @@ -57,8 +114,75 @@ export default class WorkspaceNotebooksFileSystem implements FileSystemProvider } } - async readDirectory(_uri: Uri): Promise<[string, FileType][]> { - return [] + async getMarkdownNotebooks() { + const response = await getAllWorkflows() + const data = response?.data?.workflows?.data || [] + const notebooks = data + .map((notebook) => { + const [owner, repository] = notebook.repository.split('/') + + return { + id: notebook.id, + path: `${repository}/${notebook.path}`, + repository: repository, + owner: owner, + } + }) + .sort((a, b) => a.path.localeCompare(b.path)) + + return notebooks + } + + getTreeNodes(notebooks: Workflow[]): NodesMap { + const nodes: NodesMap = {} + nodes['/'] = [] + + notebooks.forEach((notebook) => { + const parts = notebook.path.split('/') + let currentPath = '' + + // This loop will process all parts of the path, no matter how deep + for (let i = 0; i < parts.length; i++) { + const prevPath = currentPath || '/' + currentPath = currentPath ? `${currentPath}/${parts[i]}` : `/${parts[i]}` + + if (!nodes[prevPath]) { + nodes[prevPath] = [] + } + + if (!nodes[prevPath].includes(parts[i])) { + nodes[prevPath].push(parts[i]) + } + } + }) + + return nodes + } + + async readDirectory(sourceUri: Uri): Promise<[string, FileType][]> { + const uri = sourceUri.with({ authority: 'foo.com' }) + + if (!this.#notebooks.length) { + this.#notebooks = await this.getMarkdownNotebooks() + this.#nodesMap = this.getTreeNodes(this.#notebooks) + } + + if (!this.#notebooks.length) { + return [] + } + + const children = this.#nodesMap[uri.path] || [] + + const isRoot = uri.path === '/' + return children.map((child) => { + if (isRoot) { + return [child, FileType.Directory] + } + + const isDir = this.#nodesMap[`${uri.path}/${child}`] ? true : false + + return [child, isDir ? FileType.Directory : FileType.File] + }) } createDirectory(_uri: Uri): void {}