From a058a60be4bd2d86daf2e102bd2d99ef631813d7 Mon Sep 17 00:00:00 2001 From: Tyler Nullmeier Date: Tue, 28 May 2024 13:16:55 -0500 Subject: [PATCH 1/4] Remove ToC Editor Panel --- client/package-lock.json | 17 - client/package.json | 1 - .../panel-toc-editor.spec.ts.snap | 380 -------------- client/specs/panel-toc-editor.spec.ts | 418 --------------- client/src/extension-types.ts | 2 - client/src/extension.ts | 10 +- client/src/panel-toc-editor.ts | 197 -------- .../src/webview-js/toc-editor/toc-editor.jsx | 474 ------------------ client/static/toc-dark.css | 71 --- client/static/toc-editor.html | 25 - client/webpack.config.js | 1 - common/src/toc.ts | 1 - cypress/e2e/toc-editor-spec.cy.ts | 336 ------------- package.json | 7 +- snapshots.js | 259 +--------- 15 files changed, 4 insertions(+), 2195 deletions(-) delete mode 100644 client/specs/__snapshots__/panel-toc-editor.spec.ts.snap delete mode 100644 client/specs/panel-toc-editor.spec.ts delete mode 100644 client/src/panel-toc-editor.ts delete mode 100644 client/src/webview-js/toc-editor/toc-editor.jsx delete mode 100644 client/static/toc-dark.css delete mode 100644 client/static/toc-editor.html delete mode 100644 cypress/e2e/toc-editor-spec.cy.ts diff --git a/client/package-lock.json b/client/package-lock.json index de769f21..d56ee2bf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,7 +19,6 @@ "@types/vscode": "^1.89.0", "@types/xmldom": "^0.1.31", "json-stable-stringify": "^1.0.1", - "preact": "^10.6.5", "react-sortable-tree": "^2.8.0", "vscode-uri": "^3.0.3", "xmldom": "^0.6.0", @@ -326,16 +325,6 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, - "node_modules/preact": { - "version": "10.6.5", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.6.5.tgz", - "integrity": "sha512-i+LXM6JiVjQXSt2jG2vZZFapGpCuk1fl8o6ii3G84MA3xgj686FKjs4JFDkmUVhtxyq21+4ay74zqPykz9hU6w==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prop-types": { "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", @@ -865,12 +854,6 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, - "preact": { - "version": "10.6.5", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.6.5.tgz", - "integrity": "sha512-i+LXM6JiVjQXSt2jG2vZZFapGpCuk1fl8o6ii3G84MA3xgj686FKjs4JFDkmUVhtxyq21+4ay74zqPykz9hU6w==", - "dev": true - }, "prop-types": { "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", diff --git a/client/package.json b/client/package.json index 1fb1fec7..fac2a764 100644 --- a/client/package.json +++ b/client/package.json @@ -14,7 +14,6 @@ "@types/vscode": "^1.89.0", "@types/xmldom": "^0.1.31", "json-stable-stringify": "^1.0.1", - "preact": "^10.6.5", "react-sortable-tree": "^2.8.0", "vscode-uri": "^3.0.3", "xmldom": "^0.6.0", diff --git a/client/specs/__snapshots__/panel-toc-editor.spec.ts.snap b/client/specs/__snapshots__/panel-toc-editor.spec.ts.snap deleted file mode 100644 index b5c6d905..00000000 --- a/client/specs/__snapshots__/panel-toc-editor.spec.ts.snap +++ /dev/null @@ -1,380 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Toc Editor PanelTocEditor sends a message to Webview when the content is updated 1`] = ` -[ - { - "state": { - "editable": [ - { - "absPath": "/fake/path", - "language": "language", - "licenseUrl": "licenseUrl", - "slug": "slug", - "title": "title", - "tocTree": [ - { - "children": [ - { - "absPath": "/fake/path/to/file", - "fileId": "fileId", - "subtitle": "fileId", - "title": "title", - "token": "token", - "type": "TocNodeKind.Page", - }, - ], - "title": "title", - "token": "token", - "type": "TocNodeKind.Subbook", - }, - ], - "type": "BookRootNode.Singleton", - "uuid": "uuid", - }, - ], - "uneditable": [ - { - "slug": "mock-slug__source-only", - "title": "All Modules", - "tocTree": [ - { - "absPath": "/fake/path/to/file", - "fileId": "fileId", - "subtitle": "fileId", - "title": "title", - "token": "token", - "type": "TocNodeKind.Page", - }, - ], - }, - { - "slug": "mock-slug__source-only", - "title": "Orphan Modules", - "tocTree": [], - }, - ], - }, - "type": "PanelStateMessageType.Response", - }, -] -`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 1`] = `[]`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 2`] = `[]`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 3`] = ` -TocTreeItem { - "children": [ - TocTreeItem { - "children": [ - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00001/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00001", - "iconPath": "File", - "label": "Module1", - "parent": [Circular], - }, - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00002/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00002", - "iconPath": "File", - "label": "Module2", - "parent": [Circular], - }, - ], - "collapsibleState": "Collapsed", - "command": undefined, - "description": undefined, - "iconPath": "Folder", - "label": "subbook", - "parent": [Circular], - }, - ], - "collapsibleState": "Collapsed", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/collections/slug1.collection.xml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": undefined, - "iconPath": ThemeIcon {}, - "label": "Book1", - "parent": undefined, -} -`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 4`] = ` -[ - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00003/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00003", - "iconPath": "File", - "label": "Module3", - "parent": TocTreeItem { - "children": [Circular], - "collapsibleState": "Collapsed", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/collections/slug2.collection.xml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": undefined, - "iconPath": ThemeIcon {}, - "label": "Book2", - "parent": undefined, - }, - }, -] -`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 5`] = ` -TocTreeItem { - "children": [ - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00003/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00003", - "iconPath": "File", - "label": "Module3", - "parent": [Circular], - }, - ], - "collapsibleState": "Collapsed", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/collections/slug2.collection.xml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": undefined, - "iconPath": ThemeIcon {}, - "label": "Book2", - "parent": undefined, -} -`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 6`] = `undefined`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 7`] = ` -TocTreeItem { - "children": [ - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00003/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00003", - "iconPath": "File", - "label": "Module3", - "parent": [Circular], - }, - ], - "collapsibleState": "Collapsed", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/collections/slug2.collection.xml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": undefined, - "iconPath": ThemeIcon {}, - "label": "Book2", - "parent": undefined, -} -`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 8`] = ` -TocTreeItem { - "children": [ - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00001/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00001", - "iconPath": "File", - "label": "Module1", - "parent": [Circular], - }, - TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00002/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": "m00002", - "iconPath": "File", - "label": "Module2", - "parent": [Circular], - }, - ], - "collapsibleState": "Collapsed", - "command": undefined, - "description": undefined, - "iconPath": "Folder", - "label": "subbook", - "parent": TocTreeItem { - "children": [ - [Circular], - ], - "collapsibleState": "Collapsed", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/collections/slug1.collection.xml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": undefined, - "iconPath": ThemeIcon {}, - "label": "Book1", - "parent": undefined, - }, -} -`; - -exports[`Toc Editor TocTreesProvider returns expected TocTreeItems 9`] = ` -TocTreeItem { - "children": [], - "collapsibleState": "None", - "command": { - "arguments": [ - { - "$mid": 1, - "path": "/tmp/fakeworkspace/modules/m00003/index.cnxml", - "scheme": "file", - }, - ], - "command": "vscode.open", - "title": "open", - }, - "description": undefined, - "iconPath": "File", - "label": "Module3 (m00003)", - "parent": undefined, -} -`; - -exports[`Toc Editor filtering toggleTocTreesFilteringHandler 1`] = ` -[ - [ - { - "children": [], - "label": "m1", - }, - { - "expand": 3, - }, - ], - [ - { - "children": [], - "label": "m2", - "type": "TocNodeKind.Page", - }, - { - "expand": 3, - }, - ], -] -`; diff --git a/client/specs/panel-toc-editor.spec.ts b/client/specs/panel-toc-editor.spec.ts deleted file mode 100644 index 5ccd7613..00000000 --- a/client/specs/panel-toc-editor.spec.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { join } from 'path' -import { expect } from '@jest/globals' -import SinonRoot, { type SinonStub } from 'sinon' - -import vscode, { Disposable, type Event, EventEmitter, Uri, ViewColumn, type WebviewPanel } from 'vscode' -import { BookRootNode, type BookToc, TocNodeKind, TocModificationKind } from '../../common/src/toc' -import * as utils from '../src/utils' // Used for dependency mocking in tests -import { TocItemIcon, TocTreeItem, TocTreesProvider, toggleTocTreesFilteringHandler } from '../src/toc-trees-provider' -import { type PanelIncomingMessage, TocEditorPanel } from '../src/panel-toc-editor' -import { type LanguageClient } from 'vscode-languageclient/node' -import { EMPTY_BOOKS_AND_ORPHANS, ExtensionServerRequest } from '../../common/src/requests' -import { type ExtensionEvents, type ExtensionHostContext } from '../src/panel' -import { type BookOrTocNode, type TocsTreeProvider } from '../src/book-tocs' -import { type PanelStateMessage, PanelStateMessageType } from '../../common/src/webview-constants' - -const TEST_OUT_DIR = join(__dirname, '../src') -const resourceRootDir = TEST_OUT_DIR - -const createMockClient = () => { - const sendRequestStub = SinonRoot.stub() - sendRequestStub.returns([]) - const client = { - sendRequest: sendRequestStub, - onRequest: SinonRoot.stub().returns({ dispose: () => { } }) - } as unknown as LanguageClient - - return { - client, - sendRequestStub - } -} - -type ExtractEventGeneric = GenericEvent extends Event ? X : never -type ExtensionEventEmitters = { [key in keyof ExtensionEvents]: EventEmitter> } -const createMockEvents = (): { emitters: ExtensionEventEmitters, events: ExtensionEvents } => { - const onDidChangeWatchedFilesEmitter = new EventEmitter() - const emitters = { - onDidChangeWatchedFiles: onDidChangeWatchedFilesEmitter - } - const events = { - onDidChangeWatchedFiles: onDidChangeWatchedFilesEmitter.event - } - return { emitters, events } -} - -describe('Toc Editor', () => { - const sinon = SinonRoot.createSandbox() - afterEach(() => { sinon.restore() }) - it('TocTreesProvider returns expected TocTreeItems', async () => { - const fakeTreeBooks: BookToc[] = [] - fakeTreeBooks.push( - { - type: BookRootNode.Singleton, - title: 'Book1', - slug: 'slug1', - uuid: '', - language: '', - licenseUrl: '', - absPath: 'path/to/nowhere-book', - tocTree: [{ - type: TocNodeKind.Subbook, - value: { token: 'id123', title: 'subbook' }, - children: [{ - type: TocNodeKind.Page, - value: { - token: 'id234', - absPath: 'path/to/nowhere', - fileId: 'm00001', - title: 'Module1' - } - }, - { - type: TocNodeKind.Page, - value: { - token: 'id345', - absPath: 'path/to/nowhere2', - fileId: 'm00002', - title: 'Module2' - } - }] - }] - }, - { - type: BookRootNode.Singleton, - title: 'Book2', - slug: 'slug2', - uuid: '', - language: '', - licenseUrl: '', - absPath: 'path/to/nowhere-book', - tocTree: [{ - type: TocNodeKind.Page, - value: { - token: 'id123', - absPath: 'path/to/nowhere3', - fileId: 'm00003', - title: 'Module3' - } - }] - } - ) - const fakeWorkspacePath = '/tmp/fakeworkspace' - sinon.stub(utils, 'getRootPathUri').returns(vscode.Uri.file(fakeWorkspacePath)) - const module1Item = new TocTreeItem( - TocItemIcon.Page, - 'Module1', - vscode.TreeItemCollapsibleState.None, - [], - { - title: 'open', - command: 'vscode.open', - arguments: [vscode.Uri.file(`${fakeWorkspacePath}/modules/m00001/index.cnxml`)] - }, - 'm00001' - ) - const module2Item = new TocTreeItem( - TocItemIcon.Page, - 'Module2', - vscode.TreeItemCollapsibleState.None, - [], - { - title: 'open', - command: 'vscode.open', - arguments: [vscode.Uri.file(`${fakeWorkspacePath}/modules/m00002/index.cnxml`)] - }, - 'm00002' - ) - const module3Item = new TocTreeItem( - TocItemIcon.Page, - 'Module3', - vscode.TreeItemCollapsibleState.None, - [], - { - title: 'open', - command: 'vscode.open', - arguments: [vscode.Uri.file(`${fakeWorkspacePath}/modules/m00003/index.cnxml`)] - }, - 'm00003' - ) - const subbookItem = new TocTreeItem( - TocItemIcon.Subbook, - 'subbook', - vscode.TreeItemCollapsibleState.Collapsed, - [module1Item, module2Item] - ) - const book1Item = new TocTreeItem( - TocItemIcon.Book, - 'Book1', - vscode.TreeItemCollapsibleState.Collapsed, - [subbookItem], - { - title: 'open', - command: 'vscode.open', - arguments: [vscode.Uri.file(`${fakeWorkspacePath}/collections/slug1.collection.xml`)] - } - ) - const book2Item = new TocTreeItem( - TocItemIcon.Book, - 'Book2', - vscode.TreeItemCollapsibleState.Collapsed, - [module3Item], - { - title: 'open', - command: 'vscode.open', - arguments: [vscode.Uri.file(`${fakeWorkspacePath}/collections/slug2.collection.xml`)] - } - ) - - const mockClient = createMockClient().client - // We don't want to just return [] - const sendRequestMock = sinon.stub() - mockClient.sendRequest = sendRequestMock - const tocTreesProvider = new TocTreesProvider({ bookTocs: EMPTY_BOOKS_AND_ORPHANS, resourceRootDir, client: mockClient, events: createMockEvents().events }) - sendRequestMock.onCall(0).resolves(null) - sendRequestMock.onCall(1).resolves(fakeTreeBooks) - - expect(tocTreesProvider.getChildren(undefined)).toMatchSnapshot() - expect(tocTreesProvider.getChildren(undefined)).toMatchSnapshot() - expect(book1Item).toMatchSnapshot() - expect(tocTreesProvider.getChildren(book2Item)).toMatchSnapshot() - expect(tocTreesProvider.getTreeItem(book2Item)).toMatchSnapshot() - expect(await tocTreesProvider.getParent(book2Item)).toMatchSnapshot() - expect(await tocTreesProvider.getParent(module3Item)).toMatchSnapshot() - expect(await tocTreesProvider.getParent(module1Item)).toMatchSnapshot() - tocTreesProvider.toggleFilterMode() - expect(tocTreesProvider.getTreeItem(module3Item)).toMatchSnapshot() - }) - - describe('PanelTocEditor', () => { - let postMessageStub = undefined as unknown as SinonStub<[message: any], Thenable> - let onDidReceiveMessageStub: SinonRoot.SinonStub<[listener: (e: any) => any, thisArgs?: any, disposables?: vscode.Disposable[] | undefined], vscode.Disposable> - let watchedFilesSpy: SinonRoot.SinonSpy<[listener: (e: any) => any, thisArgs?: any, disposables?: vscode.Disposable[]], vscode.Disposable> - let p = undefined as unknown as TocEditorPanel - const { client, sendRequestStub } = createMockClient() - const { emitters, events } = createMockEvents() - const context: ExtensionHostContext = { - resourceRootDir: join(__dirname, '..', 'static'), // HTML webview files are loaded from here - client, - events, - bookTocs: { books: [], orphans: [] } - } - beforeEach(() => { - const webviewPanel = vscode.window.createWebviewPanel('unused', 'unused', ViewColumn.Active) - postMessageStub = sinon.stub(webviewPanel.webview, 'postMessage') - onDidReceiveMessageStub = sinon.stub(webviewPanel.webview, 'onDidReceiveMessage') - onDidReceiveMessageStub.returns(new Disposable(sinon.stub())) - - watchedFilesSpy = sinon.spy(events, 'onDidChangeWatchedFiles') - sinon.stub(vscode.window, 'createWebviewPanel').returns(webviewPanel) - - p = new TocEditorPanel(context) - }) - it('calls handleMessage when the Webview sends a message', () => { - const handleMessageStub = sinon.stub(p, 'handleMessage').returns(Promise.resolve()) - const message: PanelIncomingMessage = { - type: TocModificationKind.Remove, - nodeToken: 'my-token-id', - bookIndex: 0 - } - expect(handleMessageStub.callCount).toBe(0) - expect(onDidReceiveMessageStub.callCount).toBe(1) - const cb = onDidReceiveMessageStub.firstCall.args[0] - cb(message) - expect(handleMessageStub.callCount).toBe(1) - }) - it('translates events from the webview and sends them to the language server', async () => { - let callCount = 0 - function getMessage() { - const reqType = ExtensionServerRequest.TocModification - if (sendRequestStub.callCount <= callCount) { throw new Error('expected sendRequest to have been called but it was not') } - const c = sendRequestStub.getCall(callCount++) - if (c.firstArg !== reqType) { throw new Error(`expected the first arg of sendRequest to be '${reqType}' but it was '${c.firstArg as unknown as string}'`) } - return c.args[1].event - } - const uberEvent = { - newTitle: 'my_new_title', - nodeToken: 'mytoken', - newParentToken: undefined, - newChildIndex: 0, - bookIndex: 0 - } - - sinon.stub(vscode.workspace, 'workspaceFolders').get(() => [{ uri: Uri.file('/path/to/workspace/root') }]) - await p.handleMessage({ ...uberEvent, type: TocModificationKind.Move }) - expect(getMessage().type).toBe(TocModificationKind.Move) - await p.handleMessage({ ...uberEvent, type: TocModificationKind.Remove }) - expect(getMessage().type).toBe(TocModificationKind.Remove) - await p.handleMessage({ ...uberEvent, type: TocModificationKind.PageRename }) - expect(getMessage().type).toBe(TocModificationKind.PageRename) - await p.handleMessage({ ...uberEvent, type: TocModificationKind.SubbookRename }) - expect(getMessage().type).toBe(TocModificationKind.SubbookRename) - - sinon.stub(vscode.window, 'showInputBox').returns(Promise.resolve('new_title')) - await p.handleMessage({ type: TocNodeKind.Page, title: 'foo', bookIndex: 0, parentNodeToken: undefined }) - expect(getMessage().title).toBe('new_title') - - await p.handleMessage({ type: TocNodeKind.Subbook, title: 'foo', bookIndex: 0, slug: 'subbook_slug', parentNodeToken: undefined }) - expect(getMessage().title).toBe('new_title') - }) - it('disposes', () => { - expect(() => { p.dispose() }).not.toThrow() - }) - it('sends a message to Webview when a fileChanged event is emitted', () => { - expect(postMessageStub.callCount).toBe(0) - emitters.onDidChangeWatchedFiles.fire() - expect(postMessageStub.callCount).toBe(1) - }) - it('sends a message to Webview when the content is updated', async () => { - const testToc: BookToc = { - type: BookRootNode.Singleton, - absPath: '/fake/path', - uuid: 'uuid', - title: 'title', - slug: 'slug', - language: 'language', - licenseUrl: 'licenseUrl', - tocTree: [{ - type: TocNodeKind.Subbook, - value: { - token: 'token', - title: 'title' - }, - children: [{ - type: TocNodeKind.Page, - value: { - token: 'token', - title: 'title', - fileId: 'fileId', - absPath: '/fake/path/to/file' - } - }] - }] - } - const v1 = { books: [testToc], orphans: [] } - - expect(postMessageStub.callCount).toBe(0) - await p.update(v1) - expect(postMessageStub.callCount).toBe(1) - expect(postMessageStub.firstCall.args).toMatchSnapshot() - }) - it('does not send a message to Webview when panel is disposed', () => { - expect(p.refreshPanel({} as unknown as WebviewPanel, client)).rejects // eslint-disable-line @typescript-eslint/no-unused-expressions - }) - it('refreshes when server watched file changes', async () => { - const refreshStub = sinon.stub(p, 'refreshPanel') - await watchedFilesSpy.getCall(0).args[0](undefined) - expect(refreshStub.called).toBe(true) - }) - it('sorts pages based on fileid', async () => { - const testToc: BookToc = { - type: BookRootNode.Singleton, - absPath: '/fake/path', - uuid: 'uuid', - title: 'title', - slug: 'slug', - language: 'language', - licenseUrl: 'licenseUrl', - tocTree: [{ - type: TocNodeKind.Page, - value: { - token: 'token', - title: 'title', - fileId: 'fileId2', - absPath: '/fake/path/to/file2' - } - }, { - type: TocNodeKind.Page, - value: { - token: 'token', - title: 'title', - fileId: 'fileId1', - absPath: '/fake/path/to/file1' - } - }] - } - const v = { books: [testToc], orphans: [] } - expect(postMessageStub.callCount).toBe(0) - await p.update(v) - const message: PanelStateMessage = postMessageStub.firstCall.args[0] - expect(message.type).toBe(PanelStateMessageType.Response) - const allModules = message.state.uneditable[0] - expect(allModules.tocTree.length).toBe(2) - expect(allModules.tocTree[0].fileId).toBe('fileId1') - expect(allModules.tocTree[1].fileId).toBe('fileId2') - }) - }) - - describe('filtering', () => { - it('toggleTocTreesFilteringHandler', async () => { - const revealStub = sinon.stub() - const toggleFilterStub = sinon.stub() - const getChildrenStub = sinon.stub() - const refreshStub = sinon.stub() - - const view: vscode.TreeView = { - reveal: revealStub - } as unknown as vscode.TreeView - const provider: TocsTreeProvider = { - toggleFilterMode: toggleFilterStub, - getChildren: getChildrenStub, - refresh: refreshStub, - getParent: () => undefined - } as unknown as TocsTreeProvider - const fakeChildren = [ - { type: BookRootNode.Singleton, tocTree: [{ type: TocNodeKind.Subbook, label: 'unit1', children: [{ type: TocNodeKind.Subbook, label: 'subcol1', children: [{ type: TocNodeKind.Page, label: 'm2', children: [] }] }] }] }, - { type: BookRootNode.Singleton, tocTree: [{ label: 'm1', children: [] }] } - ] - getChildrenStub.returns(fakeChildren) - - const handler = toggleTocTreesFilteringHandler(view, provider) - await handler() - expect(toggleFilterStub.callCount).toBe(1) - expect(getChildrenStub.callCount).toBe(1) - expect(revealStub.callCount).toBe(2) - expect(revealStub.getCalls().map(c => c.args)).toMatchSnapshot() - expect(refreshStub.callCount).toBe(0) - }) - it('toggleTocTreesFilteringHandler disables itself while revealing', async () => { - const revealStub = sinon.stub() - const toggleFilterStub = sinon.stub() - const getChildrenStub = sinon.stub() - const fakeChildren = [ - { label: 'col1', children: [{ label: 'm1', children: [] }] } - ] - getChildrenStub.returns(fakeChildren) - - const view: vscode.TreeView = { - reveal: revealStub - } as unknown as vscode.TreeView - const provider: TocsTreeProvider = { - toggleFilterMode: toggleFilterStub, - getChildren: getChildrenStub, - getParent: () => undefined - } as unknown as TocsTreeProvider - - const handler = toggleTocTreesFilteringHandler(view, provider) - // Invoke the handler the first time reveal is called to simulate a parallel - // user request without resorting to synthetic delay injection - revealStub.onCall(0).callsFake(handler) - await handler() - expect(toggleFilterStub.callCount).toBe(1) - expect(revealStub.callCount).toBe(1) - expect(getChildrenStub.callCount).toBe(1) - }) - it('toggleTocTreesFilteringHandler does not lock itself on errors', async () => { - const toggleFilterStub = sinon.stub().throws() - const view: vscode.TreeView = {} as unknown as vscode.TreeView - const provider: TocsTreeProvider = { - toggleFilterMode: toggleFilterStub - } as unknown as TocsTreeProvider - - const handler = toggleTocTreesFilteringHandler(view, provider) - try { await handler() } catch { } - try { await handler() } catch { } - expect(toggleFilterStub.callCount).toBe(2) - }) - }) -}) diff --git a/client/src/extension-types.ts b/client/src/extension-types.ts index a112388f..b19a3d2c 100644 --- a/client/src/extension-types.ts +++ b/client/src/extension-types.ts @@ -1,10 +1,8 @@ export enum PanelType { - TOC_EDITOR = 'openstax.tocEditor', IMAGE_MANAGER = 'openstax.imageManager', CNXML_PREVIEW = 'openstax.cnxmlPreview' } export enum OpenstaxCommand { - SHOW_TOC_EDITOR = 'openstax.showTocEditor', SHOW_CNXML_PREVIEW = 'openstax.showPreviewToSide', SHOW_IMAGE_MANAGER = 'openstax.showImageManager' } diff --git a/client/src/extension.ts b/client/src/extension.ts index 7bd9978b..db9dacae 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -3,7 +3,6 @@ import fs from 'fs' import vscode from 'vscode' import { type LanguageClient } from 'vscode-languageclient/node' import { pushContent, validateContent, setDefaultGitConfig, initPrivateSubmodule } from './push-content' -import { TocEditorPanel } from './panel-toc-editor' import { CnxmlPreviewPanel } from './panel-cnxml-preview' import { expect, ensureCatch, ensureCatchPromise, launchLanguageServer, populateXsdSchemaFiles, getRootPathUri, configureWorkspaceSettings } from './utils' import { OpenstaxCommand } from './extension-types' @@ -79,9 +78,8 @@ function createHostContext(client: LanguageClient): ExtensionHostContext { } } -function createExports(tocPanelManager: PanelManager, cnxmlPreviewPanelManager: PanelManager, imageManagerPanelManager: PanelManager): ExtensionExports { +function createExports(cnxmlPreviewPanelManager: PanelManager, imageManagerPanelManager: PanelManager): ExtensionExports { return { - [OpenstaxCommand.SHOW_TOC_EDITOR]: tocPanelManager, [OpenstaxCommand.SHOW_CNXML_PREVIEW]: cnxmlPreviewPanelManager, [OpenstaxCommand.SHOW_IMAGE_MANAGER]: imageManagerPanelManager } @@ -89,7 +87,6 @@ function createExports(tocPanelManager: PanelManager, cnxmlPrevi function doRest(client: LanguageClient): ExtensionExports { const hostContext = createHostContext(client) - const tocPanelManager = new PanelManager(hostContext, TocEditorPanel) const cnxmlPreviewPanelManager = new PanelManager(hostContext, CnxmlPreviewPanel) const imageManagerPanelManager = new PanelManager(hostContext, ImageManagerPanel) @@ -98,12 +95,9 @@ function doRest(client: LanguageClient): ExtensionExports { client.onNotification(ExtensionServerNotification.BookTocs, (params: BooksAndOrphans) => { hostContext.bookTocs = params // When a panel opens, make sure it has the latest bookTocs tocTreesProvider.update(params.books, params.orphans) - /* istanbul ignore next */ - void tocPanelManager.panel()?.update(params) }) vscode.workspace.onDidChangeWorkspaceFolders(ensureCatch(forwardOnDidChangeWorkspaceFolders(client))) - vscode.commands.registerCommand(OpenstaxCommand.SHOW_TOC_EDITOR, tocPanelManager.revealOrNew.bind(tocPanelManager)) vscode.commands.registerCommand(OpenstaxCommand.SHOW_IMAGE_MANAGER, imageManagerPanelManager.revealOrNew.bind(imageManagerPanelManager)) vscode.commands.registerCommand(OpenstaxCommand.SHOW_CNXML_PREVIEW, cnxmlPreviewPanelManager.revealOrNew.bind(cnxmlPreviewPanelManager)) vscode.commands.registerCommand('openstax.pushContent', ensureCatch(pushContent(hostContext))) @@ -123,5 +117,5 @@ function doRest(client: LanguageClient): ExtensionExports { // It is only allowed a single handler, from what we can tell client.onRequest('onDidChangeWatchedFiles', () => { onDidChangeWatchedFilesEmitter.fire() }) - return createExports(tocPanelManager, cnxmlPreviewPanelManager, imageManagerPanelManager) + return createExports(cnxmlPreviewPanelManager, imageManagerPanelManager) } diff --git a/client/src/panel-toc-editor.ts b/client/src/panel-toc-editor.ts deleted file mode 100644 index 698cd613..00000000 --- a/client/src/panel-toc-editor.ts +++ /dev/null @@ -1,197 +0,0 @@ -import fs from 'fs' -import path from 'path' -import vscode from 'vscode' - -import { type TreeItem as TreeItemUI } from 'react-sortable-tree' -import { fixResourceReferences, fixCspSourceReferences, getRootPathUri, expect, ensureCatch } from './utils' -import { type ClientPageish, type ClientTocNode, TocNodeKind, type PageRenameEvent, type SubbookRenameEvent, type TocMoveEvent, type TocRemoveEvent, type CreatePageEvent, type CreateSubbookEvent, type TocModification, TocModificationKind, type TocModificationParams } from '../../common/src/toc' -import { PanelType } from './extension-types' -import { type LanguageClient } from 'vscode-languageclient/node' -import { type BooksAndOrphans, EMPTY_BOOKS_AND_ORPHANS, ExtensionServerRequest, type Opt } from '../../common/src/requests' -import { type ExtensionHostContext, Panel } from './panel' - -export const NS_COLLECTION = 'http://cnx.rice.edu/collxml' -export const NS_CNXML = 'http://cnx.rice.edu/cnxml' -export const NS_METADATA = 'http://cnx.rice.edu/mdml' - -export interface ErrorSignal { - type: 'error' - message: string -} -export type PanelIncomingMessage = ( - | TocMoveEvent - | TocRemoveEvent - | PageRenameEvent - | SubbookRenameEvent - | CreateSubbookEvent - | CreatePageEvent - | ErrorSignal -) - -export type TreeItemWithToken = TreeItemUI & ({ - type: TocNodeKind.Page | TocNodeKind.Ancillary - token: string - title: string | undefined - fileId: string - absPath: string -} | { - type: TocNodeKind.Subbook - token: string - title: string - children: TreeItemWithToken[] -}) -export interface Bookish { - title: string - slug: string - tocTree: TreeItemWithToken[] -} -export interface PanelState { - uneditable: Bookish[] - editable: Bookish[] -} - -function toTreeItem(n: ClientTocNode): TreeItemWithToken { - if (n.type === TocNodeKind.Page || n.type === TocNodeKind.Ancillary) { - return { - type: n.type, - token: n.value.token, - title: n.value.title, - subtitle: n.value.fileId, - fileId: n.value.fileId, - absPath: n.value.absPath - } - } else { - return { - type: n.type, - token: n.value.token, - title: n.value.title, - children: n.children.map(toTreeItem) - } - } -} - -const initPanel = (context: ExtensionHostContext): vscode.WebviewPanel => { - const localResourceRoots = [vscode.Uri.file(context.resourceRootDir)] - const workspaceRoot = getRootPathUri() - /* istanbul ignore if */ - if (workspaceRoot != null) { - localResourceRoots.push(workspaceRoot) - } - const panel = vscode.window.createWebviewPanel( - PanelType.TOC_EDITOR, - 'Table of Contents Editor', - vscode.ViewColumn.One, - { - enableScripts: true, - localResourceRoots - } - ) - return panel -} - -const isWebviewDisposed = (panel: vscode.WebviewPanel) => { - try { - // This attempted access will throw if the panel is disposed - /* eslint-disable-next-line @typescript-eslint/no-unused-expressions */ - panel.webview.html - return false - } catch { - // Do no work if the panel is disposed - return true - } -} - -const fileIdSorter = (n1: ClientPageish, n2: ClientPageish) => n1.fileId.localeCompare(n2.fileId) -const toClientTocNode = (n: ClientPageish): ClientTocNode => ({ type: TocNodeKind.Page, value: n }) -export class TocEditorPanel extends Panel { - private state = EMPTY_BOOKS_AND_ORPHANS - constructor(private readonly context: ExtensionHostContext) { - super(initPanel(context)) - - this.state = context.bookTocs - - this.registerDisposable(this.context.events.onDidChangeWatchedFiles(ensureCatch(async () => { - await this.refreshPanel(this.panel, this.context.client) - }))) - - let html = fs.readFileSync(path.join(context.resourceRootDir, 'toc-editor.html'), 'utf-8') - html = fixResourceReferences(this.panel.webview, html, context.resourceRootDir) - html = fixCspSourceReferences(this.panel.webview, html) - html = this.injectInitialState(html, this.getState()) - this.panel.webview.html = html - } - - // readonly handleMessage = handleMessageFromWebviewPanel(this.panel, this.context.client) - readonly handleMessage = async (m: PanelIncomingMessage) => { - const workspaceUri = expect(getRootPathUri(), 'No root path in which to generate a module').toString() - let event: Opt - if (m.type === TocModificationKind.Move || m.type === TocModificationKind.Remove || m.type === TocModificationKind.PageRename || m.type === TocModificationKind.SubbookRename) { - event = m - } else if (m.type === TocNodeKind.Page) { - const title = await vscode.window.showInputBox({ prompt: 'Title of new Page' }) - /* istanbul ignore if */ - if (title === undefined) { - return - } else { - event = { ...m, title } - } - } else /* istanbul ignore else */ if (m.type === TocNodeKind.Subbook) { - const title = await vscode.window.showInputBox({ prompt: 'Title of new Book Section' }) - /* istanbul ignore if */ - if (title === undefined) { - return - } else { - event = { ...m, title } - } - } - /* istanbul ignore else */ - if (event !== undefined) { - const params: TocModificationParams = { workspaceUri, event } - await this.context.client.sendRequest(ExtensionServerRequest.TocModification, params) - } - } - - async update(state: BooksAndOrphans) { - this.state = state - /* istanbul ignore else */ - if (!isWebviewDisposed(this.panel)) { - await this.sendState() - } - } - - protected getState(): PanelState { - const allModules = new Set() - function recAddModules(n: ClientTocNode) { - if (n.type === TocNodeKind.Page || n.type === TocNodeKind.Ancillary) { - allModules.add(n.value) - } else { - n.children.forEach(recAddModules) - } - } - this.state.books.forEach(b => { b.tocTree.forEach(recAddModules) }) - const orphanModules = this.state.orphans - - const allModulesSorted = Array.from(allModules).sort(fileIdSorter) - const orphanModulesSorted = orphanModules.sort(fileIdSorter) - const bookAllModules = { - title: 'All Modules', - slug: 'mock-slug__source-only', - tocTree: allModulesSorted.map(toClientTocNode).map(toTreeItem) - } - const bookOrphanModules = { - title: 'Orphan Modules', - slug: 'mock-slug__source-only', - tocTree: orphanModulesSorted.map(toClientTocNode).map(toTreeItem) - } - return { - uneditable: [bookAllModules, bookOrphanModules], - editable: this.state.books.map(b => ({ ...b, tocTree: b.tocTree.map(toTreeItem) })) - } - } - - async refreshPanel(panel: vscode.WebviewPanel, client: LanguageClient): Promise { - if (!isWebviewDisposed(panel)) { - await this.sendState() - } - } -} diff --git a/client/src/webview-js/toc-editor/toc-editor.jsx b/client/src/webview-js/toc-editor/toc-editor.jsx deleted file mode 100644 index ef4252e9..00000000 --- a/client/src/webview-js/toc-editor/toc-editor.jsx +++ /dev/null @@ -1,474 +0,0 @@ -/** @jsx PreactCreateElement */ -/** @jsxFrag PreactCreateFragment */ -import { h as PreactCreateElement, Fragment as PreactCreateFragment, render, createContext } from 'preact' // eslint-disable-line no-unused-vars -import { useState, useContext, useEffect, useRef } from 'preact/hooks' -import 'react-sortable-tree/style.css' -import SortableTree from 'react-sortable-tree' -import stringify from 'json-stable-stringify' -import { TocNodeKind, TocModificationKind } from '~common-api~/toc' -import { PanelStateMessageType } from '~common-api~/webview-constants' - -const vscode = acquireVsCodeApi() // eslint-disable-line no-undef -// vscode only allows calling acquireVsCodeApi once. -// Since we called it we will redefine the function so it does not error. -/* istanbul ignore next */ -window.acquireVsCodeApi = () => vscode - -const nodeType = 'toc-element' -const SearchContext = createContext({}) - -// Helper method to save state between loads of the page or refreshes -const saveState = (item) => { - vscode.setState(item) -} -// Helper method to get saved state between loads of the page or refreshes -const getSavedState = () => { - return vscode.getState() -} - -/** - * An element which is a `span` when not in focus, but becomes an input box - * upon becoming focused. When unfocused, or the enter key is pressed, the - * element becomes a `span` again - */ -const InputOnFocus = (props) => { - const [focus, setFocus] = useState(false) - const [value, setValue] = useState(props.value) - const inputRef = useRef(null) - - // be reactive to a value change up the tree - useEffect(() => { - setValue(props.value) - }, [props.value]) - - // focus the input box when it appears - useEffect(() => { - if (focus) { - inputRef.current.focus() - } - }, [focus]) - - const blur = (event) => { - props.onBlur(event) - setFocus(false) - } - - if (focus) { - return ( - { blur(value) }} - onChange={(event) => { setValue(event.target.value) }} - onKeyDown={(event) => { if (event.key === 'Enter') { blur(value) } }} - value={value} - /> - ) - } - return { setFocus(true) }}>{value} -} - -const ContentTree = (props) => { - const modifiesStateName = props.modifiesStateName - const [data, setData] = useState(props.data) - - // be reactive to a data change up the tree as well - useEffect(() => { - setData(props.data) - }, [props.data]) - - const { - searchQuery, - // setSearchQuery, - searchFocusIndex, - setSearchFocusIndex, - searchFoundCount, - setSearchFoundCount - } = useContext(SearchContext) - - // Adjust the search count and focus index upon a change to the search query, if possible - const searchFinishCallback = (matches) => { - if (searchFoundCount === matches.length) { - // Returning prevents infinite render loop - return - } - /* istanbul ignore if */ - if (isNaN(searchFocusIndex) || isNaN(searchFoundCount)) { - // This is a bug, but let's at least error gracefully - // instead of freezing with infinite render loop - const message = 'Divided search item by zero (probably)' - vscode.postMessage({ type: 'error', message }) - throw new Error(message) - } - setSearchFoundCount(matches.length) - setSearchFocusIndex(matches.length > 0 ? searchFocusIndex % matches.length : 0) - } - - // Custom search method so that we match case-insensitively on both the title and subtitle of items - const searchMethod = ({ node, searchQuery }) => { - if (!searchQuery) { - return false - } - const titleMatches = node.title && node.title.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1 - const subtitleMatches = node.subtitle && node.subtitle.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1 - return !!(titleMatches || subtitleMatches) - } - - /** - * Handle a potential change in 1) the structure of the tree, or 2) a title of a (sub)collection. - * If either a change is meaningful or if `force` is true. We direct the extension host to update - * the bundle with our changes in the UI. - */ - const onChange = (newChildren, force = false) => { - const { treesData, selectionIndices } = getSavedState() - - const newData = { ...data, ...{ tocTree: newChildren } } - - /* istanbul ignore if */ - if (data.tocTree.length - newChildren.length > 3) { - // There's a bug that deletes the whole tree except for one element. - // Prevent this by not allowing high magnitude deletions - return - } - - treesData[modifiesStateName][props.index].tocTree = newChildren - saveState({ treesData, selectionIndices }) - setData(newData) - } - - const getNodeProps = ({ node }) => { - const typeToColor = {} - typeToColor[TocNodeKind.Subbook] = 'green' - typeToColor[TocNodeKind.Page] = 'purple' - const bookIndex = props.index - - const typeToRenameAction = {} - // Force rewriting the tree only will change the module title as it appears in the collection file, - // but won't change the actual title inside the module content. - // We need to have the base part of the extension do that for us. - typeToRenameAction[TocNodeKind.Page] = (value) => { - if (node.title !== value) { - node.title = value - const message /*: PageRenameEvent */ = { - type: TocModificationKind.PageRename, - newTitle: value, - nodeToken: node.token, - node, - bookIndex, - newToc: data.tocTree - } - vscode.postMessage(message) - } - } - // We can change the title by just force rewriting the collection tree with the modified title - // Subbooks don't have persistent identifiers, so changing them in the base part of the - // extension would be tougher to do. - typeToRenameAction[TocNodeKind.Subbook] = (value) => { - /* istanbul ignore else */ - if (node.title !== value) { - node.title = value - const message /*: RenameSubbookEvent */ = { - type: TocModificationKind.SubbookRename, - newTitle: value, - nodeToken: node.token, - node, - bookIndex, - newToc: data.tocTree - } - vscode.postMessage(message) - } - } - const onBlur = typeToRenameAction[node.type] - /* istanbul ignore if */ - if (!onBlur) { throw new Error(`BUG: Could not find renameAction for type ${node.type}`) } - - return { - title: , - style: { - boxShadow: `0 0 0 4px ${typeToColor[node.type]}` - } - } - } - - const canDrop = ({ nextParent }) => { - if (nextParent && nextParent.type === TocNodeKind.Page) { - return false - } - return true - } - - const onMoveNode = (data /*: NodeData & FullTree & OnMovePreviousAndNextLocation */) => { - const { node, nextParentNode, path, treeData } = data - const bookIndex = props.index - const nodeToken = node.token - const newToc = treeData - if (path === null) { - // Removed node - const message /*: TocRemoveEvent */ = { - type: TocModificationKind.Remove, - nodeToken, - bookIndex, - newToc - } - vscode.postMessage(message) - } else { - const hasParent = nextParentNode !== null && nextParentNode !== undefined - /* istanbul ignore next */ - const newParentToken = hasParent ? nextParentNode.token : undefined - /* istanbul ignore next */ - const parentChildrenArray = hasParent ? nextParentNode.children : treeData - const newChildIndex = parentChildrenArray.indexOf(node) - /* istanbul ignore else */ - if (newChildIndex >= 0) { - const message /*: TocMoveEvent */ = { - type: TocModificationKind.Move, - nodeToken, - newParentToken, - newChildIndex, - bookIndex, - newToc - } - vscode.postMessage(message) - } - } - } - - const getNodeKey = (n /*: SortableTree.TreeNode */) => { - /* istanbul ignore if */ - if (!n.node?.token) { throw new Error(`missing node token: ${JSON.stringify(n.node)}`) } - return n.node.token - } - - return ( -
- {}} - onChange={onChange} // Do not update state locally. Wait for Language Server to send an updated tree - generateNodeProps={getNodeProps} - canDrop={props.editable ? canDrop : () => true} // Dropping item into non-editable trees will destroy the item - shouldCopyOnOutsideDrop={!props.editable} - dndType={nodeType} - searchQuery={searchQuery} - searchFocusOffset={searchFocusIndex} - searchFinishCallback={searchFinishCallback} - searchMethod={searchMethod} - /> -
- ) -} - -const EditorPanel = (props) => { - const modifiesStateName = props.modifiesStateName - const trees /*: PanelOutgoingMessage */ = props.treesData - const [selection, setSelection] = useState(props.selectionIndex) - - const selectedTree = trees[selection] - - const [searchQuery, setSearchQuery] = useState('') - const [searchFocusIndex, setSearchFocusIndex] = useState(0) - const [searchFoundCount, setSearchFoundCount] = useState(0) - - const selectPrevMatch = () => { - /* istanbul ignore if */ - if (searchFoundCount === 0) { - // This should not be possible due to element disabling - // But if it happens, do nothing - return - } - setSearchFocusIndex((searchFocusIndex + searchFoundCount - 1) % searchFoundCount) - } - - const selectNextMatch = () => { - /* istanbul ignore if */ - if (searchFoundCount === 0) { - // This should not be possible due to element disabling - // But if it happens, do nothing - return - } - setSearchFocusIndex((searchFocusIndex + searchFoundCount + 1) % searchFoundCount) - } - - const handleAddPage = (event) => { - vscode.postMessage({ type: TocNodeKind.Page, bookIndex: props.selectionIndex }) - } - - const handleAddSubbook = (event) => { - vscode.postMessage({ type: TocNodeKind.Subbook, slug: selectedTree.slug, bookIndex: props.selectionIndex }) - } - - const handleSelect = (event) => { - const { treesData, selectionIndices } = getSavedState() - const newSelection = parseInt(event.target.value) - selectionIndices[modifiesStateName] = newSelection - saveState({ treesData, selectionIndices }) - setSelection(newSelection) - } - - const handleSearch = (event) => { - setSearchQuery(event.target.value) - } - - const searchContext = { - searchQuery, - setSearchQuery, - searchFocusIndex, - setSearchFocusIndex, - searchFoundCount, - setSearchFoundCount - } - - const searchInfo = `${searchFoundCount > 0 ? searchFocusIndex + 1 : 0} / ${searchFoundCount}` - - return ( -
- { - selectedTree == null - ?

No data

- : <> -
- -
- { - props.canAddModules - ? - : <> - } - { - props.canAddSubbooks - ? - : <> - } -
-
- - - - { - searchQuery - ?

{searchInfo}

- : <> - } -
-
-
-
- - - -
-
- - } -
- ) -} - -const App = (props) => ( -
- - -
-) - -function walkTree(n /*: TreeItemWithToken */, fn /*: (TreeItemWithToken) => void */) { - fn(n) - if (n.children) { - n.children.forEach(c => { walkTree(c, fn) }) - } -} - -window.addEventListener('message', event => { - const previousState = getSavedState() - const oldData = previousState?.treesData - const message /*: PanelOutgoingMessage | PanelStateMessage */ = event.data - - /* istanbul ignore if */ - if (message.type !== PanelStateMessageType.Response) { - console.error('[TOC_EDITOR_WEBVIEW] BUG? Unknown Message type', message) - return - } - console.log('[TOC_EDITOR_WEBVIEW] Handling state update', message) - const newData = message.state - - if (oldData != null) { - // Copy the expanded/collapsed state for each node to the new tree based on the title of the node - for (let i = 0; i < oldData.editable.length; i++) { - const oldBook = oldData.editable[i] - const newBook = newData.editable[i] - /* istanbul ignore if */ - if (oldBook === undefined || newBook === undefined) { break } - const expandedTitles = new Map() - oldBook.tocTree.forEach(t => { walkTree(t, n => { n.expanded && expandedTitles.set(n.title, n.expanded) }) }) - newBook.tocTree.forEach(t => { walkTree(t, n => { n.expanded = expandedTitles.get(n.title) }) }) - } - } - const selectionIndices = previousState ? previousState.selectionIndices : { editable: 0, uneditable: 0 } - const appRootElement = window.document.querySelector('[data-app-init]') - if (appRootElement != null) { - appRootElement.removeAttribute('data-render-cached') - } - if (stringify(oldData) === stringify(newData) && appRootElement != null) { - // no need to re-render - appRootElement.setAttribute('data-render-cached', true) - return - } - saveState({ treesData: newData, selectionIndices }) - renderApp() -}) - -function renderApp() { - const previousState = getSavedState() - /* istanbul ignore next */ - const treesData /*: PanelOutgoingMessage */ = previousState ? previousState.treesData : { editable: [], uneditable: [] } - /* istanbul ignore next */ - const selectionIndices = previousState ? previousState.selectionIndices : { editable: 0, uneditable: 0 } - const mountPoint = document.getElementById('app') - render(, mountPoint) -} diff --git a/client/static/toc-dark.css b/client/static/toc-dark.css deleted file mode 100644 index 1f774d57..00000000 --- a/client/static/toc-dark.css +++ /dev/null @@ -1,71 +0,0 @@ -.rst__lineHalfHorizontalRight::before, -.rst__lineFullVertical::after, -.rst__lineHalfVerticalTop::after, -.rst__lineHalfVerticalBottom::after, -.rst__lineChildren::after { - background-color: var(--vscode-input-foreground); -} - -.rst__moveHandle, -.rst__loadingHandle { - background-color: #333; - border-top-left-radius: 2px; - border-bottom-left-radius: 2px; - border-top-right-radius: 0px; - border-bottom-right-radius: 0px; - border: none; - border-left: 1px; -} - -.rst__rowWrapper { - color: #333; -} - -.rst__rowContents { - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; - border-top-right-radius: 2px; - border-bottom-right-radius: 2px; - border: none; -} - -.rst__row { - border-radius: 2px; - position: relative; -} - -.rst__rowSearchMatch { - outline: none; -} - -.rst__rowSearchMatch::before { - box-shadow: #0080ff 0px 0px 3px 7px; - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: -1; - border-radius: 2px; -} - -.rst__rowSearchFocus { - outline: none; -} - -.rst__rowSearchFocus::before { - box-shadow: #fc6421 0px 0px 3px 7px; - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: -1; - border-radius: 2px; -} - -.controls > * { - margin-bottom: 0.5rem; -} diff --git a/client/static/toc-editor.html b/client/static/toc-editor.html deleted file mode 100644 index c6c3bed7..00000000 --- a/client/static/toc-editor.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - Table of Contents Editor - - - - - - - - - -
- - - diff --git a/client/webpack.config.js b/client/webpack.config.js index 3826f2a7..d5d36624 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -41,7 +41,6 @@ const viewConfig = { context: path.join(__dirname), devtool: 'source-map', entry: { - 'toc-editor': './src/webview-js/toc-editor/toc-editor.jsx', 'image-upload': './src/webview-js/image-upload/image-upload.ts', 'cnxml-preview': './src/webview-js/cnxml-preview/cnxml-preview.js' }, diff --git a/common/src/toc.ts b/common/src/toc.ts index bd300a08..50bfd15d 100644 --- a/common/src/toc.ts +++ b/common/src/toc.ts @@ -1,5 +1,4 @@ /* istanbul ignore file */ -// This enum is also hardcoded in the toc-editor Webview export enum TocNodeKind { Subbook = 'TocNodeKind.Subbook', Page = 'TocNodeKind.Page', diff --git a/cypress/e2e/toc-editor-spec.cy.ts b/cypress/e2e/toc-editor-spec.cy.ts deleted file mode 100644 index c8f4537c..00000000 --- a/cypress/e2e/toc-editor-spec.cy.ts +++ /dev/null @@ -1,336 +0,0 @@ -// Shares a namespace with the other specfiles if not scoped -import { type PanelIncomingMessage, type Bookish, type TreeItemWithToken, type PanelState } from '../../client/src/panel-toc-editor' -import { TocNodeKind } from '../../common/src/toc' -import { PanelStateMessageType } from '../../common/src/webview-constants' -{ - // The HTML file that cypress should load when running tests (relative to the project root) - const htmlPath = './client/dist/static-resources/toc-editor.html' - - const DO_NOT_INCREMENT = -1 - - type TocNode = { - title?: string - expanded?: true - children: TocNode[] - } | TocNode[] | string - let counter = 0 - const recBuildSubtree = (node: TocNode): TreeItemWithToken => { - if (typeof node === 'string') { - if (counter === DO_NOT_INCREMENT) { - --counter - } - const id = `m0000${++counter}` - return { - type: TocNodeKind.Page, - token: `page-token-${id}`, - title: node, - subtitle: id, - fileId: id, - absPath: `/fake-path/to/page/${id}` - } - } else if (Array.isArray(node)) { - return { - type: TocNodeKind.Subbook, - title: 'subbook', - token: 'subbook-token', - children: node.map(recBuildSubtree) - } - } else { - const title = node.title ?? 'subbook' - const ret: TreeItemWithToken = { - type: TocNodeKind.Subbook, - title, - token: `subbook-token-${title}`, - expanded: node.expanded ?? false, - children: node.children.map(recBuildSubtree) - } - return ret - } - } - interface BuildBookOptions { - title?: string - slug?: string - startAt?: number - } - const buildBook = (tree: TocNode[], opts: BuildBookOptions = {}): Bookish => { - const title = opts.title ?? 'test collection' - const slug = opts.slug ?? 'test' - const startAt = opts.startAt - if (startAt !== undefined) { - counter = startAt - } - return { - title, - slug, - tocTree: tree.map(recBuildSubtree) - } - } - - describe('toc-editor Webview Tests', () => { - function sendStateMessage(msg: PanelState): void { - const m = { - type: PanelStateMessageType.Response, - state: msg - } - cy.log('sending state update message', m) - cy.window().then($window => { - $window.postMessage(m, '*') - }) - } - - // When the browser calls vscode.postMessage(...) that message is added to this array - let messagesFromWidget: PanelIncomingMessage[] = [] - // When the browser calls vscode.setState, that state is stored here - let pageState: any - - beforeEach(() => { - counter = 0 - // Load the HTML file and inject the acquireVsCodeApi() stub. - cy.visit(htmlPath, { - onBeforeLoad: (contentWindow) => { - class API { - postMessage(msg: PanelIncomingMessage): void { messagesFromWidget.push(msg) } - getState(): any { return pageState } - setState(state: any): void { pageState = state } - } - (contentWindow as any).acquireVsCodeApi = () => { return new API() } - } - }) - }) - - afterEach(() => { - // Clear shared vars - messagesFromWidget = [] - pageState = undefined - }) - it('will not load until a message is sent (unreliable state store)', () => { - cy.get('[data-app-init]').should('not.exist') - }) - it('will load when a message is sent (empty)', () => { - sendStateMessage({ - editable: [], - uneditable: [] - }) - cy.get('[data-app-init]').should('exist') - cy.get('.panel-editable .rst__node').should('not.exist') - cy.get('.panel-uneditable .rst__node').should('not.exist') - }) - it('will load when a message is sent', () => { - const book = buildBook([['Introduction'], 'Appendix']) - sendStateMessage({ editable: [book], uneditable: [] }) - cy.get('[data-app-init]').should('exist') - cy.get('.panel-editable .rst__node').should('have.length', 2) - cy.get('.panel-uneditable .rst__node').should('not.exist') - }) - it('will load when a message is sent (expanded)', () => { - const book = buildBook([{ expanded: true, children: ['Introduction'] }, 'Appendix']) - sendStateMessage({ editable: [book], uneditable: [] }) - cy.get('[data-app-init]').should('exist') - cy.get('.panel-editable .rst__node').should('have.length', 3) - cy.get('.panel-uneditable .rst__node').should('not.exist') - }) - it('will not re-render on same data (expanded)', () => { - const book1 = buildBook([['Introduction']]) - const message: PanelState = { - editable: [book1], - uneditable: [] - } - sendStateMessage(message) - cy.get('[data-render-cached]').should('not.exist') - sendStateMessage(message) - cy.get('[data-render-cached]').should('exist') - - const book2 = buildBook([['Introduction'], 'Appendix']) - sendStateMessage({ editable: [book2], uneditable: [] }) - cy.get('[data-render-cached]').should('not.exist') - }) - it('will not re-render on same data (expanded)', () => { - const book1 = buildBook([{ expanded: true, children: ['Introduction'] }]) - const message: PanelState = { - editable: [book1], - uneditable: [] - } - sendStateMessage(message) - cy.get('[data-render-cached]').should('not.exist') - sendStateMessage(message) - cy.get('[data-render-cached]').should('exist') - - const book2 = buildBook([{ expanded: true, children: ['Introduction'] }, 'Appendix']) - sendStateMessage({ editable: [book2], uneditable: [] }) - cy.get('[data-render-cached]').should('not.exist') - }) - it('will preserve expanded nodes on reload', () => { - const book1 = buildBook([{ expanded: true, children: ['Introduction'] }, ['Introduction']], { startAt: DO_NOT_INCREMENT }) - sendStateMessage({ editable: [book1], uneditable: [] }) - cy.get('.panel-editable .rst__node').should('have.length', 3) - - const book2 = buildBook([{ expanded: true, children: ['Introduction'] }, ['Introduction'], ['Introduction']], { startAt: DO_NOT_INCREMENT }) - sendStateMessage({ editable: [book2], uneditable: [] }) - - // Would be 3 if the expanded subbook was not preserved - // Would be 4 if new nodes were initially collapsed - cy.get('.panel-editable .rst__node').should('have.length', 6) - }) - - describe('drag-n-drop', () => { - beforeEach(() => { - const book = buildBook([{ expanded: true, children: ['Introduction'] }, 'Appendix']) - const orphans = buildBook(['Module 3', 'Module 4']) - sendStateMessage({ - editable: [book], - uneditable: [orphans] - }) - }) - it('allows dnd from uneditable to editable', () => { - cy.get('.panel-uneditable .rst__node:nth-child(1) .rst__moveHandle') - .dnd('.panel-editable .rst__node:nth-child(2) .rst__nodeContent') - // cy.get('.panel-editable .rst__node').should('have.length', 4) - // cy.get('.panel-uneditable .rst__node').should('have.length', 2) - cy.wrap(messagesFromWidget).snapshot() - }) - it('allows dnd from editable to editable', () => { - // Drag "Appendix" on top of "Introduction" - cy.get('.panel-editable .rst__node:nth-child(3) .rst__moveHandle') - .dnd('.panel-editable .rst__node:nth-child(2) .rst__nodeContent', { offsetX: 100 }) - cy.get('.panel-editable .rst__node').should('have.length', 3) - cy.get('.panel-uneditable .rst__node').should('have.length', 2) - cy.wrap(messagesFromWidget).snapshot() - }) - it('deletes elements when dnd from editable to uneditable', () => { - // Drag "Appendix" to the orphans list - cy.get('.panel-editable .rst__node:nth-child(3) .rst__moveHandle') - .dnd('.panel-uneditable .rst__node:nth-child(1) .rst__nodeContent') - cy.wrap(messagesFromWidget).snapshot() - }) - it('disallows pages from having children', () => { - cy.get('.panel-uneditable .rst__node:nth-child(1) .rst__moveHandle') - .dnd('.panel-editable .rst__node:nth-child(3) .rst__nodeContent', { offsetX: 100 }) - cy.get('.panel-editable .rst__node').should('have.length', 3) - cy.get('.panel-uneditable .rst__node').should('have.length', 2) - cy.then(() => { - expect(messagesFromWidget).to.have.length(0) - }) - }) - }) - - describe('controls', () => { - beforeEach(() => { - const book1 = buildBook([{ expanded: true, children: ['Introduction', 'Appending To Lists'] }, 'Appendix']) - const book2 = buildBook([{ expanded: true, children: ['Introduction', 'Deleting From Lists'] }, 'Appendix'], { title: 'test collection 2', slug: 'test-2' }) - const orphans = buildBook(['Module 3', 'Module 4']) - sendStateMessage({ editable: [book1, book2], uneditable: [orphans] }) - }) - it('highlights elements that match search by title', () => { - cy.get('.panel-editable .search') - .type('append') - cy.get('.panel-editable .rst__rowSearchMatch').should('have.length', 2) - cy.get('.panel-editable .search-info').should('contain.text', '1 / 2') - }) - it('highlights elements that match search by subtitle', () => { - cy.get('.panel-editable .search') - .type('m00001') - cy.get('.panel-editable .rst__rowSearchMatch').should('have.length', 1) - cy.get('.panel-editable .search-info').should('contain.text', '1 / 1') - }) - it('focuses different elements when navigating search', () => { - cy.get('.panel-editable .search') - .type('append') - cy.get('.panel-editable .search-info').should('contain.text', '1 / 2') - cy.get('.panel-editable .rst__rowSearchFocus').should('contain.text', 'Appending') - cy.get('.panel-editable .search-next').click() - cy.get('.panel-editable .search-info').should('contain.text', '2 / 2') - cy.get('.panel-editable .rst__rowSearchFocus').should('contain.text', 'Appendix') - cy.get('.panel-editable .search-prev').click() - cy.get('.panel-editable .search-info').should('contain.text', '1 / 2') - cy.get('.panel-editable .rst__rowSearchFocus').should('contain.text', 'Appending') - }) - it('does nothing when navigating an empty search', () => { - cy.get('.panel-editable .search') - .type('no_match') - cy.get('.panel-editable .search-info').should('contain.text', '0 / 0') - cy.get('.panel-editable .rst__rowSearchFocus').should('not.exist') - cy.get('.panel-editable .search-next').should('be.disabled') - cy.get('.panel-editable .search-next').click({ force: true }) - cy.get('.panel-editable .search-info').should('contain.text', '0 / 0') - cy.get('.panel-editable .rst__rowSearchFocus').should('not.exist') - cy.get('.panel-editable .search-prev').should('be.disabled') - cy.get('.panel-editable .search-prev').click({ force: true }) - cy.get('.panel-editable .search-info').should('contain.text', '0 / 0') - cy.get('.panel-editable .rst__rowSearchFocus').should('not.exist') - }) - it('switches between trees', () => { - cy.get('.panel-editable .search') - .type('deleting') - cy.get('.panel-editable .search-info').should('contain.text', '0 / 0') - cy.get('.panel-editable .tree-select') - .select('test collection 2') - cy.get('.panel-editable .search-info').should('contain.text', '1 / 1') - }) - it('can tell the extension to create Page', () => { - cy.get('.panel-editable .page-create') - .click() - cy.then(() => { - expect(messagesFromWidget).to.have.length(1) - expect(messagesFromWidget[0].type).to.equal(TocNodeKind.Page) - }) - cy.wrap(messagesFromWidget).snapshot() - }) - it('can tell the extension to create Subbook', () => { - cy.get('.panel-editable .subbook-create') - .click() - cy.get('.panel-editable .tree-select') - .select('test collection 2') - cy.get('.panel-editable .subbook-create') - .click() - cy.then(() => { - expect(messagesFromWidget).to.have.length(2) - expect(messagesFromWidget[0].type).to.equal(TocNodeKind.Subbook) - expect(messagesFromWidget[1].type).to.equal(TocNodeKind.Subbook) - }) - cy.wrap(messagesFromWidget).snapshot() - }) - it('provides an input box when title is clicked, removes when blurred', () => { - cy.get('.panel-editable .node-title') - .eq(1) - .click() - .should('not.exist') - cy.get('.panel-editable .node-title-rename') - .eq(0) - .blur() - .should('not.exist') - }) - it('provides an input box when title is clicked, removes on Enter', () => { - cy.get('.panel-editable .node-title') - .eq(1) - .click() - .should('not.exist') - cy.get('.panel-editable .node-title-rename') - .eq(0) - .type('{enter}') - .should('not.exist') - }) - it('can tell the extension to rename Page', () => { - cy.get('.panel-editable .node-title') - .eq(1) - .click() - .should('not.exist') - cy.get('.panel-editable .node-title-rename') - .eq(0) - .type('abc', { delay: 50 }) - .blur() - cy.wrap(messagesFromWidget).snapshot() - }) - it('can tell the extension to rename Subbook', () => { - cy.get('.panel-editable .node-title') - .eq(0) - .click() - .should('not.exist') - cy.get('.panel-editable .node-title-rename') - .eq(0) - .type('abc', { delay: 50 }) - .blur() - cy.wrap(messagesFromWidget).snapshot() - }) - }) - }) -} diff --git a/package.json b/package.json index dda5fbc7..497d0405 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,6 @@ "category": "Openstax", "icon": "$(preview)" }, - { - "command": "openstax.showTocEditor", - "title": "Show ToC Editor", - "category": "Openstax" - }, { "command": "openstax.pushContent", "title": "Push Content", @@ -127,7 +122,7 @@ "viewsWelcome": [ { "view": "openstax-controls", - "contents": "[Open ToC Editor](command:openstax.showTocEditor)\n[Push Content](command:openstax.pushContent)\n[Generate README](command:openstax.generateReadme)\n[Validate Content](command:openstax.validateContent)" + "contents": "[Push Content](command:openstax.pushContent)\n[Generate README](command:openstax.generateReadme)\n[Validate Content](command:openstax.validateContent)" } ], "menus": { diff --git a/snapshots.js b/snapshots.js index 5de89150..6a71e975 100644 --- a/snapshots.js +++ b/snapshots.js @@ -1,260 +1,3 @@ module.exports = { - "__version": "13.6.0", - "toc-editor Webview Tests": { - "drag-n-drop": { - "allows dnd from uneditable to editable": { - "1": [ - { - "type": "TocModificationKind.Move", - "nodeToken": "page-token-m00003", - "newParentToken": "subbook-token-subbook", - "newChildIndex": 0, - "bookIndex": 0, - "newToc": [ - { - "type": "TocNodeKind.Subbook", - "title": "subbook", - "token": "subbook-token-subbook", - "expanded": true, - "children": [ - { - "type": "TocNodeKind.Page", - "token": "page-token-m00003", - "title": "Module 3", - "subtitle": "m00003", - "fileId": "m00003", - "absPath": "/fake-path/to/page/m00003" - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introduction", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - } - ] - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00002", - "title": "Appendix", - "subtitle": "m00002", - "fileId": "m00002", - "absPath": "/fake-path/to/page/m00002" - } - ] - } - ] - }, - "allows dnd from editable to editable": { - "1": [ - { - "type": "TocModificationKind.Move", - "nodeToken": "page-token-m00002", - "newParentToken": "subbook-token-subbook", - "newChildIndex": 0, - "bookIndex": 0, - "newToc": [ - { - "type": "TocNodeKind.Subbook", - "title": "subbook", - "token": "subbook-token-subbook", - "expanded": true, - "children": [ - { - "type": "TocNodeKind.Page", - "token": "page-token-m00002", - "title": "Appendix", - "subtitle": "m00002", - "fileId": "m00002", - "absPath": "/fake-path/to/page/m00002" - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introduction", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - } - ] - } - ] - } - ] - }, - "deletes elements when dnd from editable to uneditable": { - "1": [ - { - "type": "TocModificationKind.Remove", - "nodeToken": "page-token-m00002", - "bookIndex": 0, - "newToc": [ - { - "type": "TocNodeKind.Subbook", - "title": "subbook", - "token": "subbook-token-subbook", - "expanded": true, - "children": [ - { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introduction", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - } - ] - } - ] - } - ] - } - }, - "controls": { - "can tell the extension to create Page": { - "1": [ - { - "type": "TocNodeKind.Page", - "bookIndex": 0 - } - ] - }, - "can tell the extension to create Subbook": { - "1": [ - { - "type": "TocNodeKind.Subbook", - "slug": "test", - "bookIndex": 0 - }, - { - "type": "TocNodeKind.Subbook", - "slug": "test-2", - "bookIndex": 0 - } - ] - }, - "can tell the extension to rename Page": { - "1": [ - { - "type": "TocModificationKind.PageRename", - "newTitle": "Introductionabc", - "nodeToken": "page-token-m00001", - "node": { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introductionabc", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - }, - "bookIndex": 0, - "newToc": [ - { - "type": "TocNodeKind.Subbook", - "title": "subbook", - "token": "subbook-token-subbook", - "expanded": true, - "children": [ - { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introductionabc", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00002", - "title": "Appending To Lists", - "subtitle": "m00002", - "fileId": "m00002", - "absPath": "/fake-path/to/page/m00002" - } - ] - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00003", - "title": "Appendix", - "subtitle": "m00003", - "fileId": "m00003", - "absPath": "/fake-path/to/page/m00003" - } - ] - } - ] - }, - "can tell the extension to rename Subbook": { - "1": [ - { - "type": "TocModificationKind.SubbookRename", - "newTitle": "subbookabc", - "nodeToken": "subbook-token-subbook", - "node": { - "type": "TocNodeKind.Subbook", - "title": "subbookabc", - "token": "subbook-token-subbook", - "expanded": true, - "children": [ - { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introduction", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00002", - "title": "Appending To Lists", - "subtitle": "m00002", - "fileId": "m00002", - "absPath": "/fake-path/to/page/m00002" - } - ] - }, - "bookIndex": 0, - "newToc": [ - { - "type": "TocNodeKind.Subbook", - "title": "subbookabc", - "token": "subbook-token-subbook", - "expanded": true, - "children": [ - { - "type": "TocNodeKind.Page", - "token": "page-token-m00001", - "title": "Introduction", - "subtitle": "m00001", - "fileId": "m00001", - "absPath": "/fake-path/to/page/m00001" - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00002", - "title": "Appending To Lists", - "subtitle": "m00002", - "fileId": "m00002", - "absPath": "/fake-path/to/page/m00002" - } - ] - }, - { - "type": "TocNodeKind.Page", - "token": "page-token-m00003", - "title": "Appendix", - "subtitle": "m00003", - "fileId": "m00003", - "absPath": "/fake-path/to/page/m00003" - } - ] - } - ] - } - } - } + "__version": "13.6.0" } From c088a016d90c78180a3d7a49ecd8df5b005ad72f Mon Sep 17 00:00:00 2001 From: Tyler Nullmeier Date: Tue, 28 May 2024 13:18:15 -0500 Subject: [PATCH 2/4] Remove toc-trees-provider TocTreesProvider and TocTreeItem were only used in tests Move toggleTocTreesFilteringHandler to book-tocs --- client/specs/book-tocs.spec.ts | 77 ++++++++++++++++++++- client/src/book-tocs.ts | 58 +++++++++++++++- client/src/extension.ts | 2 +- client/src/toc-trees-provider.ts | 115 ------------------------------- 4 files changed, 133 insertions(+), 119 deletions(-) delete mode 100644 client/src/toc-trees-provider.ts diff --git a/client/specs/book-tocs.spec.ts b/client/specs/book-tocs.spec.ts index c9fa8be6..2cda5325 100644 --- a/client/specs/book-tocs.spec.ts +++ b/client/specs/book-tocs.spec.ts @@ -1,7 +1,9 @@ +import SinonRoot from 'sinon' +import type vscode from 'vscode' import { expect } from '@jest/globals' import { BookRootNode, type BookToc, type ClientTocNode, TocNodeKind } from '../../common/src/toc' -import { TocsTreeProvider } from '../src/book-tocs' +import { type BookOrTocNode, TocsTreeProvider, toggleTocTreesFilteringHandler } from '../src/book-tocs' const testTocPage: ClientTocNode = { type: TocNodeKind.Page, @@ -69,3 +71,76 @@ describe('Toc Provider', () => { expect(p.getParentBook(testToc)).toBe(undefined) }) }) + +describe('filtering', () => { + const sinon = SinonRoot.createSandbox() + afterEach(() => { sinon.restore() }) + it('toggleTocTreesFilteringHandler', async () => { + const revealStub = sinon.stub() + const toggleFilterStub = sinon.stub() + const getChildrenStub = sinon.stub() + const refreshStub = sinon.stub() + + const view: vscode.TreeView = { + reveal: revealStub + } as unknown as vscode.TreeView + const provider: TocsTreeProvider = { + toggleFilterMode: toggleFilterStub, + getChildren: getChildrenStub, + refresh: refreshStub, + getParent: () => undefined + } as unknown as TocsTreeProvider + const fakeChildren = [ + { type: BookRootNode.Singleton, tocTree: [{ type: TocNodeKind.Subbook, label: 'unit1', children: [{ type: TocNodeKind.Subbook, label: 'subcol1', children: [{ type: TocNodeKind.Page, label: 'm2', children: [] }] }] }] }, + { type: BookRootNode.Singleton, tocTree: [{ label: 'm1', children: [] }] } + ] + getChildrenStub.returns(fakeChildren) + + const handler = toggleTocTreesFilteringHandler(view, provider) + await handler() + expect(toggleFilterStub.callCount).toBe(1) + expect(getChildrenStub.callCount).toBe(1) + expect(revealStub.callCount).toBe(2) + expect(revealStub.getCalls().map(c => c.args)).toMatchSnapshot() + expect(refreshStub.callCount).toBe(0) + }) + it('toggleTocTreesFilteringHandler disables itself while revealing', async () => { + const revealStub = sinon.stub() + const toggleFilterStub = sinon.stub() + const getChildrenStub = sinon.stub() + const fakeChildren = [ + { label: 'col1', children: [{ label: 'm1', children: [] }] } + ] + getChildrenStub.returns(fakeChildren) + + const view: vscode.TreeView = { + reveal: revealStub + } as unknown as vscode.TreeView + const provider: TocsTreeProvider = { + toggleFilterMode: toggleFilterStub, + getChildren: getChildrenStub, + getParent: () => undefined + } as unknown as TocsTreeProvider + + const handler = toggleTocTreesFilteringHandler(view, provider) + // Invoke the handler the first time reveal is called to simulate a parallel + // user request without resorting to synthetic delay injection + revealStub.onCall(0).callsFake(handler) + await handler() + expect(toggleFilterStub.callCount).toBe(1) + expect(revealStub.callCount).toBe(1) + expect(getChildrenStub.callCount).toBe(1) + }) + it('toggleTocTreesFilteringHandler does not lock itself on errors', async () => { + const toggleFilterStub = sinon.stub().throws() + const view: vscode.TreeView = {} as unknown as vscode.TreeView + const provider: TocsTreeProvider = { + toggleFilterMode: toggleFilterStub + } as unknown as TocsTreeProvider + + const handler = toggleTocTreesFilteringHandler(view, provider) + try { await handler() } catch { } + try { await handler() } catch { } + expect(toggleFilterStub.callCount).toBe(2) + }) +}) diff --git a/client/src/book-tocs.ts b/client/src/book-tocs.ts index f907eef5..db1e49d3 100644 --- a/client/src/book-tocs.ts +++ b/client/src/book-tocs.ts @@ -1,12 +1,18 @@ -import { EventEmitter, type TreeItem, TreeItemCollapsibleState, Uri, type TreeDataProvider } from 'vscode' +import { EventEmitter, type TreeItem, TreeItemCollapsibleState, Uri, type TreeDataProvider, ThemeIcon } from 'vscode' import { type BookToc, type ClientTocNode, BookRootNode, TocNodeKind, type ClientPageish } from '../../common/src/toc' -import { TocItemIcon } from './toc-trees-provider' +import type vscode from 'vscode' export type BookOrTocNode = BookToc | ClientTocNode const toClientTocNode = (n: ClientPageish): ClientTocNode => ({ type: TocNodeKind.Page, value: n }) +export const TocItemIcon = { + Page: ThemeIcon.File, + Book: new ThemeIcon('book'), + Subbook: ThemeIcon.Folder +} + export class TocsTreeProvider implements TreeDataProvider { private readonly _onDidChangeTreeData = new EventEmitter() readonly onDidChangeTreeData = this._onDidChangeTreeData.event @@ -122,3 +128,51 @@ export class TocsTreeProvider implements TreeDataProvider { : this.getBookIndex(parentBook) } } + +export function toggleTocTreesFilteringHandler(view: vscode.TreeView, provider: TocsTreeProvider): () => Promise { + let revealing: boolean = false + + // We call the view.reveal API for all nodes with children to ensure the tree + // is fully expanded. This approach is used since attempting to simply call + // reveal on root notes with the max expand value of 3 doesn't seem to always + // fully expose leaf nodes for large trees. + function leafFinder(acc: ClientTocNode[], elements: BookOrTocNode[]) { + for (const el of elements) { + if (el.type === BookRootNode.Singleton) { + leafFinder(acc, el.tocTree) + } else if (el.type === TocNodeKind.Subbook) { + leafFinder(acc, el.children) + } else { + acc.push(el) + } + } + } + + return async () => { + // Avoid parallel processing of requests by ignoring if we're actively + // revealing + if (revealing) { return } + revealing = true + + try { + // Toggle data provider filter mode and reveal all children so the + // tree expands if it hasn't already + provider.toggleFilterMode() + const leaves: ClientTocNode[] = [] + leafFinder(leaves, provider.getChildren()) + const nodes3Up = new Set() // VSCode allows expanding up to 3 levels down + leaves.forEach(l => { + const p1 = provider.getParent(l) + const p2 = p1 === undefined ? undefined : /* istanbul ignore next */ provider.getParent(p1) + const p3 = p2 === undefined ? undefined : /* istanbul ignore next */ provider.getParent(p2) + /* istanbul ignore next */ + nodes3Up.add(p3 ?? p2 ?? p1 ?? l) + }) + for (const node of Array.from(nodes3Up).reverse()) { + await view.reveal(node, { expand: 3 }) + } + } finally { + revealing = false + } + } +} diff --git a/client/src/extension.ts b/client/src/extension.ts index db9dacae..94f5170c 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -8,7 +8,7 @@ import { expect, ensureCatch, ensureCatchPromise, launchLanguageServer, populate import { OpenstaxCommand } from './extension-types' import { type ExtensionHostContext, type Panel, PanelManager } from './panel' import { ImageManagerPanel } from './panel-image-manager' -import { toggleTocTreesFilteringHandler } from './toc-trees-provider' +import { toggleTocTreesFilteringHandler } from './book-tocs' import { type BookOrTocNode, TocsTreeProvider } from './book-tocs' import { type BooksAndOrphans, EMPTY_BOOKS_AND_ORPHANS, ExtensionServerNotification } from '../../common/src/requests' import { readmeGenerator } from './generate-readme' diff --git a/client/src/toc-trees-provider.ts b/client/src/toc-trees-provider.ts deleted file mode 100644 index 4adb0d70..00000000 --- a/client/src/toc-trees-provider.ts +++ /dev/null @@ -1,115 +0,0 @@ -import vscode, { ThemeIcon } from 'vscode' -import { BookRootNode, type ClientTocNode, TocNodeKind } from '../../common/src/toc' -import { type TocsTreeProvider, type BookOrTocNode } from './book-tocs' -import { type ExtensionHostContext } from './panel' - -export const TocItemIcon = { - Page: ThemeIcon.File, - Book: new ThemeIcon('book'), - Subbook: ThemeIcon.Folder -} - -export class TocTreesProvider implements vscode.TreeDataProvider { - private readonly _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter() - readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event - private isFilterMode = false - - constructor(private readonly context: ExtensionHostContext) { - this.context.events.onDidChangeWatchedFiles(this.refresh.bind(this)) - } - - toggleFilterMode(): void { - this.isFilterMode = !this.isFilterMode - this.refresh() - } - - refresh(): void { - this._onDidChangeTreeData.fire(undefined) - } - - getTreeItem(element: TocTreeItem): TocTreeItem { - if (this.isFilterMode && (element.description != null)) { - return new TocTreeItem( - element.iconPath, - `${element.label} (${element.description})`, - element.collapsibleState, - element.children, - element.command, - undefined - ) - } - return element - } - - getChildren(element?: TocTreeItem): TocTreeItem[] { - return element?.children ?? [] - } - - getParent(element: TocTreeItem): vscode.ProviderResult { - return element.parent - } -} - -export class TocTreeItem extends vscode.TreeItem { - parent: TocTreeItem | undefined = undefined - - constructor( - public readonly iconPath: ThemeIcon, - public readonly label: string, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly children: TocTreeItem[], - public readonly command?: vscode.Command, - public readonly description?: string - ) { - super(label, collapsibleState) - this.children.forEach(child => { child.parent = this }) - } -} - -export function toggleTocTreesFilteringHandler(view: vscode.TreeView, provider: TocsTreeProvider): () => Promise { - let revealing: boolean = false - - // We call the view.reveal API for all nodes with children to ensure the tree - // is fully expanded. This approach is used since attempting to simply call - // reveal on root notes with the max expand value of 3 doesn't seem to always - // fully expose leaf nodes for large trees. - function leafFinder(acc: ClientTocNode[], elements: BookOrTocNode[]) { - for (const el of elements) { - if (el.type === BookRootNode.Singleton) { - leafFinder(acc, el.tocTree) - } else if (el.type === TocNodeKind.Subbook) { - leafFinder(acc, el.children) - } else { - acc.push(el) - } - } - } - - return async () => { - // Avoid parallel processing of requests by ignoring if we're actively - // revealing - if (revealing) { return } - revealing = true - - try { - // Toggle data provider filter mode and reveal all children so the - // tree expands if it hasn't already - provider.toggleFilterMode() - const leaves: ClientTocNode[] = [] - leafFinder(leaves, provider.getChildren()) - const nodes3Up = new Set() // VSCode allows expanding up to 3 levels down - leaves.forEach(l => { - const p1 = provider.getParent(l) - const p2 = p1 === undefined ? undefined : /* istanbul ignore next */ provider.getParent(p1) - const p3 = p2 === undefined ? undefined : /* istanbul ignore next */ provider.getParent(p2) - /* istanbul ignore next */ - nodes3Up.add(p3 ?? p2 ?? p1 ?? l) - }) - for (const node of Array.from(nodes3Up).reverse()) { - await view.reveal(node, { expand: 3 }) - } - } finally { - revealing = false - } - } -} From d3ea23c3de2e8c8857e54db596cad1d814a8aede Mon Sep 17 00:00:00 2001 From: Tyler Nullmeier Date: Tue, 28 May 2024 13:19:29 -0500 Subject: [PATCH 3/4] Update test for toc updates --- client/specs/extension.spec.ts | 14 ++++++-------- client/src/extension.ts | 5 +++++ client/src/panel.ts | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/client/specs/extension.spec.ts b/client/specs/extension.spec.ts index 0a3c8aa4..ccecce9f 100644 --- a/client/specs/extension.spec.ts +++ b/client/specs/extension.spec.ts @@ -2,14 +2,13 @@ import { join } from 'path' import { expect } from '@jest/globals' import mockfs from 'mock-fs' -import { activate, deactivate, forwardOnDidChangeWorkspaceFolders, setLanguageServerLauncher, setResourceRootDir } from '../src/extension' +import { activate, deactivate, forwardOnDidChangeWorkspaceFolders, getTocTree, setLanguageServerLauncher, setResourceRootDir } from '../src/extension' import { type Extension, type ExtensionContext, type WebviewPanel } from 'vscode' import * as vscode from 'vscode' import * as utils from '../src/utils' // Used for dependency mocking in tests import Sinon from 'sinon' import { type LanguageClient } from 'vscode-languageclient/node' import { OpenstaxCommand } from '../src/extension-types' -import { type TocEditorPanel } from '../src/panel-toc-editor' import { type BooksAndOrphans, ExtensionServerNotification } from '../../common/src/requests' import { type PanelManager } from '../src/panel' import { type CnxmlPreviewPanel } from '../src/panel-cnxml-preview' @@ -53,20 +52,19 @@ describe('Extension', () => { it('Starts up', async function () { await expect(activate(extensionContext)).resolves.toBeTruthy() }) - it('updates the TocPanel when the language server sends a BookTocs Notification', async () => { + it('updates the TreeView when the language server sends a BookTocs Notification', async () => { setResourceRootDir(join(__dirname, '..', 'static')) - const extensions = await activate(extensionContext) - const pm = extensions[OpenstaxCommand.SHOW_TOC_EDITOR] - expect(pm.panel()).toBeNull() + await activate(extensionContext) expect(onNotificationStub.firstCall.args[0]).toBe(ExtensionServerNotification.BookTocs) const cb = onNotificationStub.firstCall.args[1] const params: BooksAndOrphans = { books: [], orphans: [] } cb(params) - const panel = pm.newPanel() as TocEditorPanel - const updateStub = sinon.stub(panel, 'update') + const { tocTreesProvider } = getTocTree() + if (tocTreesProvider == null) throw new Error('tocTreesProvider was not set yet') + const updateStub = sinon.stub(tocTreesProvider, 'update') cb(params) expect(updateStub.callCount).toBe(1) diff --git a/client/src/extension.ts b/client/src/extension.ts index 94f5170c..01f50ef9 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -34,6 +34,11 @@ export function setLanguageServerLauncher(l: typeof languageServerLauncher) { export const forwardOnDidChangeWorkspaceFolders = (clientInner: LanguageClient) => async (event: vscode.WorkspaceFoldersChangeEvent) => { await clientInner.sendRequest('onDidChangeWorkspaceFolders', event) } +export const getTocTree = (): { + tocTreesView: vscode.TreeView | undefined | null + tocTreesProvider: TocsTreeProvider | undefined | null + tocEventHandler: TocsEventHandler | undefined | null +} => ({ tocTreesView, tocTreesProvider, tocEventHandler }) type ExtensionExports = { [key in OpenstaxCommand]: PanelManager> } export async function activate(context: vscode.ExtensionContext): Promise { diff --git a/client/src/panel.ts b/client/src/panel.ts index 9172984d..fd06af18 100644 --- a/client/src/panel.ts +++ b/client/src/panel.ts @@ -80,7 +80,7 @@ export abstract class Panel implements DisposableS this.panel.onDidDispose(() => { this.dispose() }) this.registerDisposable(this.panel.webview.onDidReceiveMessage((message) => { - /* istanbul ignore if */ + /* istanbul ignore next */ if (message.type === PanelStateMessageType.Request) { void ensureCatchPromise(this.sendState()) } else { From dccbb81a4316e369d9e33f1f011ba4a0df056750 Mon Sep 17 00:00:00 2001 From: Tyler Nullmeier Date: Thu, 8 Aug 2024 09:35:37 -0500 Subject: [PATCH 4/4] Update book-tocs snapshot --- .../__snapshots__/book-tocs.spec.ts.snap | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/client/specs/__snapshots__/book-tocs.spec.ts.snap b/client/specs/__snapshots__/book-tocs.spec.ts.snap index 228940fa..9546d088 100644 --- a/client/specs/__snapshots__/book-tocs.spec.ts.snap +++ b/client/specs/__snapshots__/book-tocs.spec.ts.snap @@ -145,3 +145,27 @@ exports[`Toc Provider returns tree items for children 4`] = ` "label": "title", } `; + +exports[`filtering toggleTocTreesFilteringHandler 1`] = ` +[ + [ + { + "children": [], + "label": "m1", + }, + { + "expand": 3, + }, + ], + [ + { + "children": [], + "label": "m2", + "type": "TocNodeKind.Page", + }, + { + "expand": 3, + }, + ], +] +`;