diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f10a5bc1f..c0975770c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,5 +4,9 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - # Check for updates to GitHub Actions every weekday - interval: "weekly" + # Check for updates to GitHub Actions every month + interval: "monthly" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/auto-author-assign.yml b/.github/workflows/auto-author-assign.yml index 9a0d281b7..d470e28c8 100644 --- a/.github/workflows/auto-author-assign.yml +++ b/.github/workflows/auto-author-assign.yml @@ -12,4 +12,4 @@ jobs: assign-author: runs-on: ubuntu-latest steps: - - uses: toshimaru/auto-author-assign@v2.0.1 + - uses: toshimaru/auto-author-assign@v2.1.0 diff --git a/.github/workflows/check-api-changes.yml b/.github/workflows/check-api-changes.yml index d086e3aaf..2390ab849 100644 --- a/.github/workflows/check-api-changes.yml +++ b/.github/workflows/check-api-changes.yml @@ -27,7 +27,7 @@ jobs: shell: bash run: echo "::set-output name=dir::$(yarn cache dir)" - name: Cache yarn - uses: actions/cache@v3 + uses: actions/cache@v4 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -52,7 +52,7 @@ jobs: - name: Get API changes id: api-changed - uses: tj-actions/changed-files@v40.2.1 + uses: tj-actions/changed-files@v42.0.5 with: base_sha: 'HEAD~1' sha: 'HEAD' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 90ccee72f..d4e0537df 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml @@ -55,7 +55,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -69,4 +69,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/prep-release.yml b/.github/workflows/prep-release.yml index 6f0928106..396330bb9 100644 --- a/.github/workflows/prep-release.yml +++ b/.github/workflows/prep-release.yml @@ -12,6 +12,10 @@ on: post_version_spec: description: "Post Version Specifier" required: false + silent: + description: "Set a placeholder in the changelog and don't publish the release." + required: false + type: boolean since: description: "Use PRs with activity since this date or git reference" required: false @@ -22,6 +26,8 @@ on: jobs: prep_release: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -29,9 +35,11 @@ jobs: id: prep-release uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 with: - token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} version_spec: ${{ github.event.inputs.version_spec }} + silent: ${{ github.event.inputs.silent }} post_version_spec: ${{ github.event.inputs.post_version_spec }} + target: ${{ github.event.inputs.target }} branch: ${{ github.event.inputs.branch }} since: ${{ github.event.inputs.since }} since_last_stable: ${{ github.event.inputs.since_last_stable }} diff --git a/.github/workflows/publish-changelog.yml b/.github/workflows/publish-changelog.yml new file mode 100644 index 000000000..60af4c5f1 --- /dev/null +++ b/.github/workflows/publish-changelog.yml @@ -0,0 +1,34 @@ +name: "Publish Changelog" +on: + release: + types: [published] + + workflow_dispatch: + inputs: + branch: + description: "The branch to target" + required: false + +jobs: + publish_changelog: + runs-on: ubuntu-latest + environment: release + steps: + - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Publish changelog + id: publish-changelog + uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 + with: + token: ${{ steps.app-token.outputs.token }} + branch: ${{ github.event.inputs.branch }} + + - name: "** Next Step **" + run: | + echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bd1be1158..c1881060d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,18 +15,23 @@ on: jobs: publish_release: runs-on: ubuntu-latest + environment: release permissions: - # This is useful if you want to use PyPI trusted publisher - # and NPM provenance id-token: write steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Populate Release id: populate-release uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 with: - token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} branch: ${{ github.event.inputs.branch }} release_url: ${{ github.event.inputs.release_url }} steps_to_skip: ${{ github.event.inputs.steps_to_skip }} @@ -35,9 +40,9 @@ jobs: id: finalize-release env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: jupyter-server/jupyter-releaser/.github/actions/finalize-release@v2 + uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 with: - token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} release_url: ${{ steps.populate-release.outputs.release_url }} - name: "** Next Step **" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce2936b2a..800616d53 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,7 +51,7 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Set up browser cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ${{ github.workspace }}/pw-browsers diff --git a/README.md b/README.md index 8ef7d53e4..d3b5dc0e2 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The example can be interacted with live in the browser by following this link: - [Using Lumino in a Vue.js application](https://github.com/kinow/vue-lumino) - [wasmboy: Game Boy / Game Boy Color Emulator Library, written for WebAssembly using AssemblyScript](https://github.com/torch2424/wasmboy) - [Web Components (Docking, Split and Tab Panels)](http://hpcc-systems.github.io/Visualization/components/README.html) +- [Using Lumino in Vue3](https://github.com/novrain/vue3-lumino-widget) ## Usage diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index bc7dc61c9..ec62ee21e 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -517,7 +517,7 @@ export class CommandRegistry { } // Get the normalized keystroke for the event. - let keystroke = CommandRegistry.keystrokeForKeydownEvent(event); + const keystroke = CommandRegistry.keystrokeForKeydownEvent(event); // If the keystroke is not valid for the keyboard layout, replay // any suppressed events and clear the pending state. @@ -551,15 +551,17 @@ export class CommandRegistry { this._keystrokes.push(keystroke); // Find the exact and partial matches for the key sequence. - let { exact, partial } = Private.matchKeyBinding( + const { exact, partial } = Private.matchKeyBinding( this._keyBindings, this._keystrokes, event ); + // Whether there is any partial match. + const hasPartial = partial.length !== 0; // If there is no exact match and no partial match, replay // any suppressed events and clear the pending state. - if (!exact && !partial) { + if (!exact && !hasPartial) { this._replayKeydownEvents(); this._clearPendingState(); return; @@ -567,13 +569,19 @@ export class CommandRegistry { // Stop propagation of the event. If there is only a partial match, // the event will be replayed if a final exact match never occurs. - event.preventDefault(); - event.stopPropagation(); + if (exact?.preventDefault || partial.some(match => match.preventDefault)) { + event.preventDefault(); + event.stopPropagation(); + } + + // Store the event for possible playback in the future and for + // the use in execution hold check. + this._keydownEvents.push(event); // If there is an exact match but no partial match, the exact match // can be dispatched immediately. The pending state is cleared so // the next key press starts from the default state. - if (exact && !partial) { + if (exact && !hasPartial) { this._executeKeyBinding(exact); this._clearPendingState(); return; @@ -586,14 +594,26 @@ export class CommandRegistry { this._exactKeyMatch = exact; } - // Store the event for possible playback in the future. - this._keydownEvents.push(event); - // (Re)start the timer to dispatch the most recent exact match // in case the partial match fails to result in an exact match. this._startTimer(); } + /** + * Delay the execution of any command matched against the given 'keydown' event + * until the `permission` to execute is granted. + * + * @param event - The event object for a `'keydown'` event. + * @param permission - The promise with value indicating whether to proceed with the execution. + * + * ### Note + * This enables the caller of `processKeydownEvent` to asynchronously prevent the + * execution of the command based on external events. + */ + holdKeyBindingExecution(event: KeyboardEvent, permission: Promise) { + this._holdKeyBindingPromises.set(event, permission); + } + /** * Process a ``keyup`` event to clear the timer on the modifier, if it exists. * @@ -660,8 +680,38 @@ export class CommandRegistry { * Execute the command for the given key binding. * * If the command is missing or disabled, a warning will be logged. - */ - private _executeKeyBinding(binding: CommandRegistry.IKeyBinding): void { + * + * The execution will not proceed if any of the events leading to + * the keybinding matching were held with the permission resolving to false. + */ + private async _executeKeyBinding( + binding: CommandRegistry.IKeyBinding + ): Promise { + if (this._holdKeyBindingPromises.size !== 0) { + // Copy keydown events list to ensure it is available in async code. + const keydownEvents = [...this._keydownEvents]; + // Wait until all hold requests on execution are lifted. + const executionAllowed = ( + await Promise.race([ + Promise.all( + keydownEvents.map( + async event => + this._holdKeyBindingPromises.get(event) ?? Promise.resolve(true) + ) + ), + new Promise(resolve => { + setTimeout(() => resolve([false]), Private.KEYBINDING_HOLD_TIMEOUT); + }) + ]) + ).every(Boolean); + // Clear the hold requests. + this._holdKeyBindingPromises.clear(); + // Do not proceed with the execution if any of the hold requests did not get the permission to proceed. + if (!executionAllowed) { + return; + } + } + let { command, args } = binding; let newArgs: ReadonlyPartialJSONObject = { _luminoEvent: { type: 'keybinding', keys: binding.keys }, @@ -675,7 +725,7 @@ export class CommandRegistry { console.warn(`${msg1} ${msg2}`); return; } - this.execute(command, newArgs); + await this.execute(command, newArgs); } /** @@ -721,6 +771,7 @@ export class CommandRegistry { this, CommandRegistry.IKeyBindingChangedArgs >(this); + private _holdKeyBindingPromises = new Map>(); } /** @@ -1073,6 +1124,13 @@ export namespace CommandRegistry { * If provided, this will override `keys` on Linux platforms. */ linuxKeys?: string[]; + + /** + * Whether to prevent default action of the keyboard events during sequence matching. + * + * The default value is `true`. + */ + preventDefault?: boolean; } /** @@ -1101,6 +1159,13 @@ export namespace CommandRegistry { * The arguments for the command. */ readonly args: ReadonlyPartialJSONObject; + + /** + * Whether to prevent default action of the keyboard events during sequence matching. + * + * The default value is `true`. + */ + readonly preventDefault?: boolean; } /** @@ -1342,6 +1407,11 @@ namespace Private { */ export const CHORD_TIMEOUT = 1000; + /** + * The timeout in ms for stopping the hold on keybinding execution. + */ + export const KEYBINDING_HOLD_TIMEOUT = 1000; + /** * The timeout in ms for triggering a modifer key binding. */ @@ -1426,7 +1496,8 @@ namespace Private { keys: CommandRegistry.normalizeKeys(options), selector: validateSelector(options), command: options.command, - args: options.args || JSONExt.emptyObject + args: options.args || JSONExt.emptyObject, + preventDefault: options.preventDefault ?? true }; } @@ -1440,9 +1511,9 @@ namespace Private { exact: CommandRegistry.IKeyBinding | null; /** - * Whether there are bindings which partially match the sequence. + * The key bindings which partially match the sequence. */ - partial: boolean; + partial: CommandRegistry.IKeyBinding[]; } /** @@ -1459,8 +1530,8 @@ namespace Private { // The current best exact match. let exact: CommandRegistry.IKeyBinding | null = null; - // Whether a partial match has been found. - let partial = false; + // Partial matches. + let partial = []; // The match distance for the exact match. let distance = Infinity; @@ -1484,8 +1555,8 @@ namespace Private { // If it is a partial match and no other partial match has been // found, ensure the selector matches and set the partial flag. if (sqm === SequenceMatch.Partial) { - if (!partial && targetDistance(binding.selector, event) !== -1) { - partial = true; + if (targetDistance(binding.selector, event) !== -1) { + partial.push(binding); } continue; } diff --git a/packages/commands/tests/src/index.spec.ts b/packages/commands/tests/src/index.spec.ts index 1c5da2857..5b1216b11 100644 --- a/packages/commands/tests/src/index.spec.ts +++ b/packages/commands/tests/src/index.spec.ts @@ -821,6 +821,70 @@ describe('@lumino/commands', () => { expect(called).to.equal(false); }); + it('should prevent default on dispatch', () => { + registry.addCommand('test', { + execute: () => void 0 + }); + registry.addKeyBinding({ + keys: ['Ctrl ;'], + selector: `#${elem.id}`, + command: 'test' + }); + const event = new KeyboardEvent('keydown', { + keyCode: 59, + ctrlKey: true + }); + let defaultPrevented = false; + event.preventDefault = () => { + defaultPrevented = true; + }; + elem.dispatchEvent(event); + expect(defaultPrevented).to.equal(true); + }); + + it('should not prevent default when sequence does not match', () => { + registry.addCommand('test', { + execute: () => void 0 + }); + registry.addKeyBinding({ + keys: ['Ctrl ;'], + selector: `#${elem.id}`, + command: 'test' + }); + const event = new KeyboardEvent('keydown', { + keyCode: 59, + ctrlKey: false + }); + let defaultPrevented = false; + event.preventDefault = () => { + defaultPrevented = true; + }; + elem.dispatchEvent(event); + expect(defaultPrevented).to.equal(false); + }); + + it('should not prevent default if keybinding opts out', () => { + registry.addCommand('test', { + execute: () => void 0 + }); + registry.addKeyBinding({ + keys: ['Ctrl ;'], + selector: `#${elem.id}`, + command: 'test', + preventDefault: false + }); + const event = new KeyboardEvent('keydown', { + keyCode: 59, + ctrlKey: true + }); + let defaultPrevented = false; + event.preventDefault = () => { + defaultPrevented = true; + }; + elem.dispatchEvent(event); + expect(defaultPrevented).to.equal(false); + }); + it('should dispatch with multiple chords in a key sequence', () => { let count = 0; registry.addCommand('test', { @@ -1258,6 +1322,85 @@ describe('@lumino/commands', () => { }); }); + describe('.holdKeyBindingExecution()', () => { + let calledPromise: Promise; + let execute: () => void; + + beforeEach(() => { + calledPromise = Promise.race([ + new Promise(_resolve => { + execute = () => _resolve(true); + }), + new Promise(resolve => + setTimeout(() => resolve(false), 1000) + ) + ]); + }); + + it('should proceed with command execution if permission of the event resolves to true', async () => { + registry.addCommand('test', { + execute + }); + registry.addKeyBinding({ + keys: ['Ctrl ;'], + selector: `#${elem.id}`, + command: 'test' + }); + const event = new KeyboardEvent('keydown', { + keyCode: 59, + ctrlKey: true + }); + registry.holdKeyBindingExecution(event, Promise.resolve(true)); + elem.dispatchEvent(event); + const called = await calledPromise; + expect(called).to.equal(true); + }); + + it('should prevent command execution if permission of the event resolves to false', async () => { + registry.addCommand('test', { + execute + }); + registry.addKeyBinding({ + keys: ['Ctrl ;'], + selector: `#${elem.id}`, + command: 'test' + }); + const event = new KeyboardEvent('keydown', { + keyCode: 59, + ctrlKey: true + }); + registry.holdKeyBindingExecution(event, Promise.resolve(false)); + elem.dispatchEvent(event); + const called = await calledPromise; + expect(called).to.equal(false); + }); + + it('should prevent command execution if permission for any of the events resolves to false', async () => { + registry.addCommand('test', { + execute + }); + registry.addKeyBinding({ + keys: ['Shift ['], + selector: `#${elem.id}`, + command: 'test' + }); + const shiftEvent = new KeyboardEvent('keydown', { + keyCode: 16, + shiftKey: true + }); + const bracketEvent = new KeyboardEvent('keydown', { + keyCode: 219, + shiftKey: true + }); + registry.holdKeyBindingExecution(shiftEvent, Promise.resolve(true)); + registry.holdKeyBindingExecution(bracketEvent, Promise.resolve(false)); + elem.dispatchEvent(shiftEvent); + elem.dispatchEvent(bracketEvent); + const called = await calledPromise; + expect(called).to.equal(false); + }); + }); + describe('.parseKeystroke()', () => { it('should parse a keystroke into its parts', () => { let parts = CommandRegistry.parseKeystroke('Ctrl Shift Alt S'); diff --git a/review/api/commands.api.md b/review/api/commands.api.md index 7b95d2fee..f5859fdda 100644 --- a/review/api/commands.api.md +++ b/review/api/commands.api.md @@ -22,6 +22,7 @@ export class CommandRegistry { describedBy(id: string, args?: ReadonlyPartialJSONObject): Promise; execute(id: string, args?: ReadonlyPartialJSONObject): Promise; hasCommand(id: string): boolean; + holdKeyBindingExecution(event: KeyboardEvent, permission: Promise): void; icon(id: string, args?: ReadonlyPartialJSONObject): VirtualElement.IRenderer | undefined; iconClass(id: string, args?: ReadonlyPartialJSONObject): string; iconLabel(id: string, args?: ReadonlyPartialJSONObject): string; @@ -80,6 +81,7 @@ export namespace CommandRegistry { readonly args: ReadonlyPartialJSONObject; readonly command: string; readonly keys: ReadonlyArray; + readonly preventDefault?: boolean; readonly selector: string; } export interface IKeyBindingChangedArgs { @@ -92,6 +94,7 @@ export namespace CommandRegistry { keys: string[]; linuxKeys?: string[]; macKeys?: string[]; + preventDefault?: boolean; selector: string; winKeys?: string[]; } diff --git a/yarn.lock b/yarn.lock index 844fa8bab..c8860de87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4807,12 +4807,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.0": - version: 1.15.2 - resolution: "follow-redirects@npm:1.15.2" + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" peerDependenciesMeta: debug: optional: true - checksum: faa66059b66358ba65c234c2f2a37fcec029dc22775f35d9ad6abac56003268baf41e55f9ee645957b32c7d9f62baf1f0b906e68267276f54ec4b4c597c2b190 + checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5 languageName: node linkType: hard @@ -5811,9 +5811,9 @@ __metadata: linkType: hard "ip@npm:^2.0.0": - version: 2.0.0 - resolution: "ip@npm:2.0.0" - checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349 + version: 2.0.1 + resolution: "ip@npm:2.0.1" + checksum: d765c9fd212b8a99023a4cde6a558a054c298d640fec1020567494d257afd78ca77e37126b1a3ef0e053646ced79a816bf50621d38d5e768cdde0431fa3b0d35 languageName: node linkType: hard