Windows app title bar menu. (#9542)

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>
This commit is contained in:
Denis 'GeneralGDA' Gladkiy
2025-07-16 13:02:04 +07:00
committed by GitHub
parent 042679abec
commit cde9d062be
26 changed files with 2047 additions and 83 deletions
+3 -1
View File
@@ -320,7 +320,7 @@ sudo npm install -g @microsoft/rush
#### WSL Configuration
Edit the `/etc/wsl.conf` file in WSL (e.g., `sudo nano /etc/wsl.conf`) and add the following content if it doesn't exist:
If the source code is located on a Windows NTFS drive, then edit the `/etc/wsl.conf` file in WSL (e.g., `sudo nano /etc/wsl.conf`) and add the following content if it doesn't exist:
```ini
[automount]
@@ -332,6 +332,8 @@ options = "metadata,umask=22,fmask=11"
appendWindowsPath = false
```
However, we recommend storing the repository on a WSL disk, as this dramatically improves build and maintenance operations.
### Running the Application
After these preparations, the build instructions should work without issues.
+60 -3
View File
@@ -282,7 +282,7 @@ importers:
version: file:projects/datalake.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)
'@rush-temp/desktop':
specifier: file:./projects/desktop.tgz
version: file:projects/desktop.tgz(@babel/core@7.23.9)(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4)
version: file:projects/desktop.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4)
'@rush-temp/desktop-1':
specifier: file:./projects/desktop-1.tgz
version: file:projects/desktop-1.tgz(webpack@5.97.1)
@@ -1423,6 +1423,9 @@ importers:
'@telegraf/entity':
specifier: ^0.5.0
version: 0.5.0
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.6.3
'@tiptap/core':
specifier: ^2.11.7
version: 2.11.7(@tiptap/pm@2.11.7)
@@ -2303,6 +2306,9 @@ packages:
resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==}
engines: {node: '>=0.10.0'}
'@adobe/css-tools@4.4.3':
resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -4216,7 +4222,7 @@ packages:
version: 0.0.0
'@rush-temp/desktop@file:projects/desktop.tgz':
resolution: {integrity: sha512-VqYfpEML7x0UunCGG5DzQaRWyrTPlfazb33/1O3HtQJWutFSqZOwTrqZlOjaK9JASToMcjZWn3QVvS0r4NzJkQ==, tarball: file:projects/desktop.tgz}
resolution: {integrity: sha512-Sb8w7pX5RxmFjOGtXHVJJc1AAqeqMpJi7v1/SHEQXmcK+SDghXtatRyCynNNEYPmwQgRIxs2u35Jjga5LVo0/w==, tarball: file:projects/desktop.tgz}
version: 0.0.0
'@rush-temp/devmodel-resources@file:projects/devmodel-resources.tgz':
@@ -5985,6 +5991,10 @@ packages:
'@telegraf/types@7.1.0':
resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==}
'@testing-library/jest-dom@6.6.3':
resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@tiptap/core@2.11.7':
resolution: {integrity: sha512-zN+NFFxLsxNEL8Qioc+DL6b8+Tt2bmRbXH22Gk6F6nD30x83eaUSFlSv3wqvgyCq3I1i1NO394So+Agmayx6rQ==}
peerDependencies:
@@ -7558,6 +7568,10 @@ packages:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -7943,6 +7957,9 @@ packages:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -8403,6 +8420,9 @@ packages:
docx4js@3.2.20:
resolution: {integrity: sha512-u7kfMRYAHsczusgnrRAnZ0bXSF1HqiQmKtAI5LNIoQfrYXUHmaukcXnJR98KEFW3fSVdYKuLyHP9vKhrJKzetw==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dom-converter@0.2.0:
resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==}
@@ -12156,6 +12176,10 @@ packages:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
reflect.getprototypeof@1.0.6:
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
engines: {node: '>= 0.4'}
@@ -13893,6 +13917,8 @@ snapshots:
'@aashutoshrathi/word-wrap@1.2.6': {}
'@adobe/css-tools@4.4.3': {}
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.2.1':
@@ -18466,8 +18492,10 @@ snapshots:
- supports-color
- ts-node
'@rush-temp/desktop@file:projects/desktop.tgz(@babel/core@7.23.9)(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4)':
'@rush-temp/desktop@file:projects/desktop.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4)':
dependencies:
'@testing-library/jest-dom': 6.6.3
'@types/jest': 29.5.12
'@types/node': 22.15.29
'@types/ws': 8.5.11
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3)
@@ -18499,6 +18527,7 @@ snapshots:
file-loader: 6.2.0(webpack@5.97.1)
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.8.3)(webpack@5.97.1)
html-webpack-plugin: 5.6.0(webpack@5.97.1)
jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
livekit-client: 2.13.3
mini-css-extract-plugin: 2.8.0(webpack@5.97.1)
node-loader: 2.0.0(webpack@5.97.1)
@@ -18512,6 +18541,7 @@ snapshots:
svelte-loader: 3.2.0(svelte@4.2.19)
svelte-preprocess: 5.1.3(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(sass@1.71.1)(svelte@4.2.19)(typescript@5.8.3)
svgo-loader: 3.0.3
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3)
ts-loader: 9.5.1(typescript@5.8.3)(webpack@5.97.1)
ts-node: 10.9.2(@types/node@22.15.29)(typescript@5.8.3)
ts-node-dev: 2.0.0(@types/node@22.15.29)(typescript@5.8.3)
@@ -18524,11 +18554,14 @@ snapshots:
ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- '@rspack/core'
- '@swc/core'
- '@swc/wasm'
- '@types/dom-mediacapture-record'
- '@webpack-cli/generators'
- babel-jest
- babel-plugin-macros
- bufferutil
- coffeescript
- debug
@@ -28870,6 +28903,16 @@ snapshots:
'@telegraf/types@7.1.0': {}
'@testing-library/jest-dom@6.6.3':
dependencies:
'@adobe/css-tools': 4.4.3
aria-query: 5.3.0
chalk: 3.0.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
lodash: 4.17.21
redent: 3.0.0
'@tiptap/core@2.11.7(@tiptap/pm@2.11.7)':
dependencies:
'@tiptap/pm': 2.11.7
@@ -30763,6 +30806,11 @@ snapshots:
escape-string-regexp: 1.0.5
supports-color: 5.5.0
chalk@3.0.0:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -31207,6 +31255,8 @@ snapshots:
css-what@6.1.0: {}
css.escape@1.5.1: {}
cssesc@3.0.0: {}
csso@4.2.0:
@@ -31661,6 +31711,8 @@ snapshots:
htmlparser2: 3.10.1
jszip: 2.7.0
dom-accessibility-api@0.6.3: {}
dom-converter@0.2.0:
dependencies:
utila: 0.4.0
@@ -36144,6 +36196,11 @@ snapshots:
dependencies:
resolve: 1.22.8
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
reflect.getprototypeof@1.0.6:
dependencies:
call-bind: 1.0.7
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
projects: [
{
displayName: 'node',
testEnvironment: 'node',
preset: 'ts-jest',
testMatch: ['<rootDir>/src/__test__/main/**/*.test.ts']
},
{
displayName: 'jsdom',
testEnvironment: 'jsdom',
preset: 'ts-jest',
testMatch: ['<rootDir>/src/__test__/ui/**/*.test.ts']
}
],
roots: ["./src", "./tests"],
coverageReporters: ["text-summary", "html"],
}
+8 -1
View File
@@ -8,12 +8,15 @@
"build:watch": "tsc",
"_phase:package": "rushx package",
"_phase:validate": "compile validate",
"_phase:test": "jest --passWithNoTests --silent --forceExit --detectOpenHandles",
"package": "rushx bump && cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production NODE_OPTIONS='--max-old-space-size=4094' webpack --stats-error-details && echo 'done'",
"webpack": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development webpack --stats-error-details --progress -w",
"devp": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production CLIENT_TYPE=dev webpack --progress -w",
"dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development webpack --progress -w",
"start": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production electron --no-sandbox .",
"start-dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development electron --no-sandbox .",
"test": "jest --passWithNoTests --silent --forceExit --verbose --detectOpenHandles",
"test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --no-cache",
"format": "format",
"bump": "bump-package-version"
},
@@ -61,7 +64,11 @@
"esbuild": "^0.24.2",
"esbuild-loader": "^4.0.3",
"svelte-preprocess": "^5.1.3",
"@types/ws": "^8.5.11"
"@types/ws": "^8.5.11",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@testing-library/jest-dom": "^6.6.3"
},
"dependencies": {
"@hcengineering/platform": "^0.6.11",
@@ -0,0 +1,22 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
describe('node environment self checking', () => {
test('check Jest\'s node environment', () => {
const isNodeAvailable = typeof process !== 'undefined';
expect(isNodeAvailable).toBe(true);
})
})
+365
View File
@@ -0,0 +1,365 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import '@testing-library/jest-dom';
import { MenuBuilder } from '../../ui/titleBarMenu';
import { MenuBarAction } from '../../ui/types';
describe('MenuBuilder', () => {
let systemUnderTest: MenuBuilder;
beforeEach(() => {
systemUnderTest = new MenuBuilder();
document.body.innerHTML = '';
});
function verifyTopLevelMenu(menuBar: HTMLElement, topLevelMenu: number, expectedMenuName: string, expectedAccelerator: string) {
const menuButton = menuBar.children[topLevelMenu].children[0] as HTMLElement;
expect(menuButton.dataset.menu).toBe(expectedMenuName);
expect(menuButton.dataset.accelerator).toBe(expectedAccelerator);
}
describe('addTopLevelMenu', () => {
test('add a top-level menu', () => {
systemUnderTest.addTopLevelMenu('File', 'f');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(1);
verifyTopLevelMenu(builtMenu, 0, 'file', 'f');
});
test('method chaining', () => {
const result = systemUnderTest
.addTopLevelMenu('File', 'f')
.addTopLevelMenu('Edit', 'e');
expect(result).toBe(systemUnderTest);
});
test('add multiple top-level menus', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addTopLevelMenu('Edit', 'e')
.addTopLevelMenu('View', 'v');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(3);
verifyTopLevelMenu(builtMenu, 0, 'file', 'f');
verifyTopLevelMenu(builtMenu, 1, 'edit', 'e');
verifyTopLevelMenu(builtMenu, 2, 'view', 'v');
});
});
describe('addMenuItem', () => {
beforeEach(() => {
systemUnderTest.addTopLevelMenu('File', 'f');
});
test('add a menu item to existing top-level menu', () => {
systemUnderTest.addMenuItem(0, 'New', 'redo', 'Ctrl+N');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(1);
const menuItem = dropdown.children[0] as HTMLElement;
expect(menuItem.dataset.action).toBe('redo');
});
test('add menu item with custom accelerator character', () => {
systemUnderTest.addMenuItem(0, 'Save As', 'select-all', 'Ctrl+Shift+S', 'a');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
const menuItem = dropdown.children[0] as HTMLElement;
expect(menuItem.dataset.accelerator).toBe('a');
});
test('default accelerator', () => {
systemUnderTest.addMenuItem(0, 'Open', 'undo', 'Ctrl+O');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
const menuItem = dropdown.children[0] as HTMLElement;
expect(menuItem.dataset.accelerator).toBe('o');
});
test.each([[999], [-1],])("invalid top-level menu index", (input: number) => {
systemUnderTest.addMenuItem(input, 'Invalid', 'invalid' as MenuBarAction, 'Ctrl+I');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(0);
});
test('add multiple menu items to the same top-level menu', () => {
systemUnderTest
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
.addMenuItem(0, 'Save', 'copy', 'Ctrl+S');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(3);
});
});
describe('addSeparator', () => {
beforeEach(() => {
systemUnderTest.addTopLevelMenu('File', 'f');
});
test('add a separator', () => {
systemUnderTest.addSeparator(0);
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(1);
expect(dropdown.children[0]).toHaveClass('desktop-app-dropdown-separator');
});
test.each([[999], [-1],])("invalid top-level menu index", (input: number) => {
systemUnderTest.addSeparator(999);
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(0);
});
});
describe('build', () => {
test('build empty menu bar', () => {
const builtMenu = systemUnderTest.build();
expect(builtMenu).toHaveClass('desktop-app-menu-bar');
expect(builtMenu.children).toHaveLength(0);
});
test('menu bar with complex structure of a single top-level menu', () => {
const topLevelMenu = 0;
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(topLevelMenu, 'New', 'paste', 'Ctrl+N')
.addSeparator(topLevelMenu)
.addMenuItem(topLevelMenu, 'Exit', 'exit', 'Alt+F4');
const builtMenu = systemUnderTest.build();
expect(builtMenu).toHaveClass('desktop-app-menu-bar');
expect(builtMenu.children).toHaveLength(1);
const topLevelMenuItem = builtMenu.children[0];
expect(topLevelMenuItem).toHaveClass('desktop-app-menu-item');
expect(topLevelMenuItem.children).toHaveLength(2);
const topButton = topLevelMenuItem.children[0];
expect(topButton).toHaveClass('desktop-app-top-menu-button');
const dropdown = topLevelMenuItem.children[1];
expect(dropdown).toHaveClass('desktop-app-dropdown-menu');
expect(dropdown.id).toBe('file-menu');
expect(dropdown.children).toHaveLength(3); // New, separator, Exit
});
test('accelerator at the beginning of label', () => {
systemUnderTest.addTopLevelMenu('File', 'f');
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('File');
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator');
expect(acceleratorSpan).toBeTruthy();
expect(acceleratorSpan?.textContent).toBe('F');
});
test('accelerator in the middle of label', () => {
systemUnderTest.addTopLevelMenu('Edit', 'i'); // 'i' is in the middle of 'Edit'
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('Edit');
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator');
expect(acceleratorSpan).toBeTruthy();
expect(acceleratorSpan?.textContent).toBe('i');
});
test('accelerator not found in label', () => {
systemUnderTest.addTopLevelMenu('File', 'z'); // 'z' is not in 'File'
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('File');
expect(topButton.querySelector('.desktop-app-accelerator')).toBeNull();
});
test('dropdown items with shortcuts', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N');
const builtMenu = systemUnderTest.build();
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement;
expect(dropdownItem).toHaveClass('desktop-app-dropdown-item');
expect(dropdownItem.dataset.action).toBe('paste');
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut');
expect(shortcutSpan).toBeTruthy();
expect(shortcutSpan?.textContent).toBe('Ctrl+N');
});
test('without shortcuts when empty', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', '');
const builtMenu = systemUnderTest.build();
const dropdownItem = builtMenu.children[0].children[1].children[0];
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut');
expect(shortcutSpan).toBeNull();
});
test('accelerator characters in dropdown items', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O', 'p');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
const newItem = dropdown.children[0] as HTMLElement;
const openItem = dropdown.children[1] as HTMLElement;
expect(newItem.dataset.accelerator).toBe('n');
expect(openItem.dataset.accelerator).toBe('p');
const newAccelerator = newItem.querySelector('.desktop-app-accelerator');
const openAccelerator = openItem.querySelector('.desktop-app-accelerator');
expect(newAccelerator?.textContent).toBe('N');
expect(openAccelerator?.textContent).toBe('p');
});
test('complex menu structure', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
.addSeparator(0)
.addMenuItem(0, 'Save', 'cut', 'Ctrl+S')
.addMenuItem(0, 'Save As', 'cut', 'Ctrl+Shift+S')
.addSeparator(0)
.addMenuItem(0, 'Exit', 'cut', 'Alt+F4')
.addTopLevelMenu('Edit', 'e')
.addMenuItem(1, 'Undo', 'cut', 'Ctrl+Z')
.addMenuItem(1, 'Redo', 'cut', 'Ctrl+Y');
const builtMenu = systemUnderTest.build();
// Should have 2 top-level menus
expect(builtMenu.children).toHaveLength(2);
// File menu should have 7 items (5 menu items + 2 separators)
const fileDropdown = builtMenu.children[0].children[1];
expect(fileDropdown.children).toHaveLength(7);
// Edit menu should have 2 items
const editDropdown = builtMenu.children[1].children[1];
expect(editDropdown.children).toHaveLength(2);
});
});
describe('edge cases', () => {
test('empty labels', () => {
systemUnderTest
.addTopLevelMenu('', 'f')
.addMenuItem(0, '', 'cut', 'shortcut');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(1);
const dropdown = builtMenu.children[0].children[1];
expect(dropdown).toHaveClass('desktop-app-dropdown-menu');
expect(dropdown.children).toHaveLength(1);
});
test('special characters in labels', () => {
systemUnderTest
.addTopLevelMenu('File & Edit', 'f')
.addMenuItem(0, 'Save & Exit', 'cut', 'Ctrl+S');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(1);
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('File & Edit');
});
test('case-insensitive accelerator matching', () => {
systemUnderTest
.addTopLevelMenu('File', 'F') // uppercase F
.addMenuItem(0, 'New', 'paste', 'Ctrl+N', 'N'); // uppercase N
const builtMenu = systemUnderTest.build();
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement;
expect(dropdownItem.dataset.accelerator).toBe('n');
const topButton = builtMenu.children[0].children[0] as HTMLElement;
expect(topButton.dataset.accelerator).toBe('f');
// Check that the actual character from the label is preserved
const topAccelerator = topButton.querySelector('.desktop-app-accelerator');
const itemAccelerator = dropdownItem.querySelector('.desktop-app-accelerator');
expect(topAccelerator?.textContent).toBe('F');
expect(itemAccelerator?.textContent).toBe('N');
});
test('unicode characters', () => {
systemUnderTest
.addTopLevelMenu('Файл', 'ф')
.addMenuItem(0, 'Новый', 'undo', 'Ctrl+N');
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('Файл');
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator');
expect(acceleratorSpan?.textContent).toBe('Ф');
});
});
});
@@ -0,0 +1,24 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
describe('jsdom environment self checking', () => {
test('check Jest\'s jsdom environment', () => {
const isDOMAvailable = typeof document !== 'undefined';
expect(isDOMAvailable).toBe(true);
const isWindowAvailable = typeof window !== 'undefined';
expect(isWindowAvailable).toBe(true);
})
})
@@ -0,0 +1,301 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { TitleBarMenuState } from '../../ui/titleBarMenuState'
describe('TitleBarMenuState', () => {
let systemUnderTest: TitleBarMenuState
let mockTopLevelMenuCount: jest.Mock
let mockMenuItemsCount: jest.Mock
const TopLevelMenuCount = 3
const MenuItemCount = 5
beforeEach(() => {
mockTopLevelMenuCount = jest.fn().mockReturnValue(TopLevelMenuCount)
mockMenuItemsCount = jest.fn().mockReturnValue(MenuItemCount)
systemUnderTest = new TitleBarMenuState(mockTopLevelMenuCount, mockMenuItemsCount)
})
describe('construction', () => {
test('initializes with default values', () => {
expect(systemUnderTest.isAltModeActive).toBe(false)
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
})
})
describe('focusChildMenu', () => {
test.each([[0],[1]])('when top level menu %i is expanded', (topLevelMenu: number) => {
systemUnderTest.expandTopLevelMenu(topLevelMenu)
systemUnderTest.focusChildMenu()
expect(systemUnderTest.FocusedChildMenuIndex).toBe(0)
})
test('when alt mode and top level menu is not expanded', () => {
systemUnderTest.enterAltMode(1)
systemUnderTest.focusChildMenu()
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
})
test('when no top level menu is focused', () => {
systemUnderTest.focusChildMenu()
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
expect(systemUnderTest.isAltModeActive).toBe(false)
})
})
describe('expandTopLevelMenu', () => {
test('normal expansion', () => {
const targetMenuIndex = 1
systemUnderTest.expandTopLevelMenu(targetMenuIndex)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(targetMenuIndex)
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(true)
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
expect(systemUnderTest.isAltModeActive).toBe(false)
})
test.each([[-1],[TopLevelMenuCount],[TopLevelMenuCount+1]])('invalid index %i', (invalidIndex: number) => {
systemUnderTest.expandTopLevelMenu(invalidIndex)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
})
})
describe('enterAltMode', () => {
test('normal enter', () => {
systemUnderTest.enterAltMode(2)
expect(systemUnderTest.isAltModeActive).toBe(true)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(2)
})
test.each([[-1],[TopLevelMenuCount],[TopLevelMenuCount+1]])('invalid index %i', (invalidIndex: number) => {
systemUnderTest.enterAltMode(invalidIndex)
expect(systemUnderTest.isAltModeActive).toBe(true)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
})
})
describe('closeAll', () => {
test('resets all state to initial values', () => {
systemUnderTest.enterAltMode(1)
systemUnderTest.expandTopLevelMenu(1)
systemUnderTest.focusChildMenu()
systemUnderTest.closeAll()
expect(systemUnderTest.isAltModeActive).toBe(false)
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
})
})
describe('defocus', () => {
test('when top level menu expanded', () => {
systemUnderTest.expandTopLevelMenu(1)
systemUnderTest.focusChildMenu()
systemUnderTest.defocus()
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(1)
expect(systemUnderTest.isAltModeActive).toBe(false)
})
test('when top level menu is not expanded', () => {
systemUnderTest.enterAltMode(1)
systemUnderTest.defocus()
expect(systemUnderTest.isAltModeActive).toBe(false)
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
expect(systemUnderTest.isAltModeActive).toBe(false)
})
})
describe('moveFocusHorizontal', () => {
const DefaultTopLevelFocus = 1
beforeEach(() => {
systemUnderTest.enterAltMode(DefaultTopLevelFocus)
})
test.each([
[+1, DefaultTopLevelFocus + 1],
[-1, DefaultTopLevelFocus - 1],
])('with increment %i, and top menu collapsed', (incremet: number, expectedTopLevelFocus: number) => {
const focusedChildBefore = systemUnderTest.FocusedChildMenuIndex
systemUnderTest.moveFocusHorizontal(incremet)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(expectedTopLevelFocus)
expect(systemUnderTest.FocusedChildMenuIndex).toBe(focusedChildBefore)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test.each([
[+1, DefaultTopLevelFocus + 1],
[-1, DefaultTopLevelFocus - 1],
])('with increment %i, and top menu expanded', (incremet: number, expectedTopLevelFocus: number) => {
systemUnderTest.expandTopLevelMenu(DefaultTopLevelFocus)
systemUnderTest.focusChildMenu()
systemUnderTest.moveFocusVertical(1)
systemUnderTest.moveFocusHorizontal(incremet)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(expectedTopLevelFocus)
expect(systemUnderTest.FocusedChildMenuIndex).toBe(0)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('positive wrap around', () => {
systemUnderTest.enterAltMode(TopLevelMenuCount-1)
systemUnderTest.moveFocusHorizontal(1)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(0)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('negative wrap around', () => {
systemUnderTest.enterAltMode(0)
systemUnderTest.moveFocusHorizontal(-1)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(TopLevelMenuCount-1)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test.each([[-2],[2],[0]])('invalid increment %i', (invalidIncrement: number) => {
const originalIndex = systemUnderTest.FocusedTopLevelMenuIndex
systemUnderTest.moveFocusHorizontal(invalidIncrement)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(originalIndex)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('handles null focused index', () => {
systemUnderTest.closeAll()
systemUnderTest.moveFocusHorizontal(1)
expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull()
expect(systemUnderTest.isAltModeActive).toBe(false)
})
})
describe('moveFocusVertical', () => {
beforeEach(() => {
systemUnderTest.enterAltMode(1)
})
test('when top level menu is not expanded', () => {
systemUnderTest.moveFocusVertical(1)
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(true)
expect(systemUnderTest.FocusedChildMenuIndex).toBe(0)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('negative wrap around', () => {
systemUnderTest.expandTopLevelMenu(1)
systemUnderTest.focusChildMenu()
systemUnderTest.moveFocusVertical(-1)
expect(systemUnderTest.FocusedChildMenuIndex).toBe(MenuItemCount-1)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('positive wrap around', () => {
systemUnderTest.expandTopLevelMenu(1)
systemUnderTest.focusChildMenu()
for (let i = 0; i < MenuItemCount; i++) {
systemUnderTest.moveFocusVertical(1)
}
expect(systemUnderTest.FocusedChildMenuIndex).toBe(0)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('invalid increment', () => {
systemUnderTest.expandTopLevelMenu(1)
systemUnderTest.focusChildMenu()
const originalIndex = systemUnderTest.FocusedChildMenuIndex
systemUnderTest.moveFocusVertical(2)
expect(systemUnderTest.FocusedChildMenuIndex).toBe(originalIndex)
expect(systemUnderTest.isAltModeActive).toBe(true)
})
test('when no top level menu is focused', () => {
systemUnderTest.closeAll()
systemUnderTest.moveFocusVertical(1)
expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false)
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
expect(systemUnderTest.isAltModeActive).toBe(false)
})
})
describe('edge cases', () => {
test('zero menu count', () => {
mockTopLevelMenuCount.mockReturnValue(0)
const emptyMenuState = new TitleBarMenuState(mockTopLevelMenuCount, mockMenuItemsCount)
emptyMenuState.enterAltMode(0)
expect(emptyMenuState.isAltModeActive).toBe(true)
expect(emptyMenuState.isTopLevelMenuExpanded).toBe(false)
expect(emptyMenuState.FocusedChildMenuIndex).toBeNull
expect(emptyMenuState.FocusedTopLevelMenuIndex).toBeNull
})
test('zero menu items count', () => {
mockMenuItemsCount.mockReturnValue(0)
systemUnderTest.enterAltMode(1)
systemUnderTest.moveFocusVertical(1)
expect(systemUnderTest.FocusedChildMenuIndex).toBeNull()
})
})
})
@@ -0,0 +1,29 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { isMenuBarAction } from '../../ui/typesUtils'
import { MenuBarActions } from '../../ui/types'
describe('isMenuBarAction', () => {
test('yes', () => {
for (const action of MenuBarActions) {
expect(isMenuBarAction(action)).toBe(true)
}
})
test('no', () => {
expect(isMenuBarAction('some random string')).toBe(false)
})
})
+92
View File
@@ -0,0 +1,92 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { app, BrowserWindow } from 'electron'
import { MenuBarAction, StandardMenuCommandLogout, StandardMenuCommandSelectWorkspace, StandardMenuCommandOpenSettings } from '../ui/types'
export function dipatchMenuBarAction(mainWindow: BrowserWindow | undefined, action: MenuBarAction) {
if (mainWindow == null) {
return
}
function performZoom(increment: number): void {
if (mainWindow == null) {
return
}
const currentZoom = mainWindow.webContents.getZoomFactor();
mainWindow.webContents.setZoomFactor(currentZoom + increment);
}
const zoomStep = 0.1;
switch (action) {
case 'settings':
mainWindow.webContents.send(StandardMenuCommandOpenSettings)
break;
case 'select-workspace':
mainWindow.webContents.send(StandardMenuCommandSelectWorkspace)
break;
case 'logout':
mainWindow.webContents.send(StandardMenuCommandLogout)
break;
case 'exit':
app.quit();
break;
case 'undo':
mainWindow.webContents.undo();
break;
case 'redo':
mainWindow.webContents.redo();
break;
case 'cut':
mainWindow.webContents.cut();
break;
case 'copy':
mainWindow.webContents.copy();
break;
case 'paste':
mainWindow.webContents.paste();
break;
case 'delete':
mainWindow.webContents.delete();
break;
case 'select-all':
mainWindow.webContents.selectAll();
break;
case 'reload':
mainWindow?.reload();
break;
case 'force-reload':
mainWindow.webContents.reloadIgnoringCache();
break;
case 'toggle-devtools':
mainWindow.webContents.toggleDevTools();
break;
case 'zoom-in':
performZoom(+zoomStep);
break;
case 'zoom-out':
performZoom(-zoomStep);
break;
case 'restore-size':
mainWindow.webContents.setZoomFactor(1.0);
break;
case 'toggle-fullscreen':
mainWindow.setFullScreen(!mainWindow.isFullScreen());
break;
default:
console.log('unknown menu action:', action);
}
}
@@ -13,12 +13,13 @@
// limitations under the License.
//
import { Menu, MenuItemConstructorOptions, type BrowserWindow } from 'electron'
import { Menu, MenuItemConstructorOptions } from 'electron'
import { StandardMenuCommand, StandardMenuCommandOpenSettings, StandardMenuCommandSelectWorkspace, StandardMenuCommandLogout, } from '../ui/types'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
export const addMenus = (getWindow: () => BrowserWindow, sendCommand: (cmd: string, ...args: any[]) => void): void => {
export const addMenus = (sendCommand: (cmd: StandardMenuCommand, ...args: any[]) => void): void => {
const template: MenuItemConstructorOptions[] = [
{
label: 'File',
@@ -26,15 +27,15 @@ export const addMenus = (getWindow: () => BrowserWindow, sendCommand: (cmd: stri
{
label: 'Settings',
accelerator: isLinux ? 'Ctrl+,' : 'Meta+,',
click: () => { sendCommand('open-settings') }
click: () => { sendCommand(StandardMenuCommandOpenSettings) }
},
{
label: 'Select workspace',
click: () => { sendCommand('select-workspace') }
click: () => { sendCommand(StandardMenuCommandSelectWorkspace) }
},
{
label: 'Logout',
click: () => { sendCommand('logout') }
click: () => { sendCommand(StandardMenuCommandLogout) }
},
{ role: isMac ? 'close' : 'quit' }
]
+74 -21
View File
@@ -14,7 +14,7 @@
//
import { config as dotenvConfig } from 'dotenv'
import { BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences } from 'electron'
import { BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron'
import contextMenu from 'electron-context-menu'
import log from 'electron-log'
import Store from 'electron-store'
@@ -22,9 +22,10 @@ import { ProgressInfo, UpdateInfo } from 'electron-updater'
import WinBadge from 'electron-windows-badge'
import * as path from 'path'
import { Config, NotificationParams } from '../ui/types'
import { Config, MenuBarAction, NotificationParams, StandardMenuCommandLogout, StandardMenuCommandSelectWorkspace, StandardMenuCommandOpenSettings } from '../ui/types'
import { getOptions } from './args'
import { addMenus } from './menu'
import { addMenus } from './standardMenu'
import { dipatchMenuBarAction } from './customMenu'
import { addPermissionHandlers } from './permissions'
import autoUpdater from './updater'
import { generateId } from '@hcengineering/core'
@@ -39,6 +40,7 @@ const isDev = process.env.NODE_ENV === 'development'
const sessionPartition = !isDev ? 'persist:huly' : 'persist:huly_dev'
const iconKey = path.join(app.getAppPath(), 'dist', 'ui', 'public', 'AppIcon.png')
const preloadScriptPath = path.join(app.getAppPath(), 'dist', 'main', 'preload.js')
const defaultWidth = 1440
const defaultHeight = 960
@@ -50,6 +52,9 @@ dotenvConfig({
})
const options = getOptions()
const containerPageFileName = isWindows ? 'index.windows.html' : 'index.html'
const containerPagePath = path.join('dist', 'ui', containerPageFileName)
// Note: using electron-store here as local storage is not available in the main process
// before the window is created
const settings = new Store()
@@ -87,6 +92,15 @@ const disabledFeatures = [
app.commandLine.appendSwitch('disable-features', disabledFeatures.join(','))
function setupWindowTitleBar(windowOptions: Electron.BrowserWindowConstructorOptions): void {
if (isWindows) {
// on Windows we use frameless window with custom hand-made title bar
windowOptions.frame = false
} else {
windowOptions.titleBarStyle = isMac ? 'hidden' : 'default'
}
}
function hookOpenWindow (window: BrowserWindow): void {
window.webContents.setWindowOpenHandler(({ url }) => {
console.log('opening window', url)
@@ -104,12 +118,11 @@ function hookOpenWindow (window: BrowserWindow): void {
} else {
void (async (): Promise<void> => {
const bounds = mainWindow?.getBounds()
const childWindow = new BrowserWindow({
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: bounds?.width ?? defaultWidth,
height: bounds?.height ?? defaultHeight,
x: (bounds?.x ?? 0) + 25,
y: (bounds?.y ?? 0) + 25,
titleBarStyle: isMac ? 'hidden' : 'default',
trafficLightPosition: { x: 10, y: 10 },
icon: nativeImage.createFromPath(iconKey),
webPreferences: {
@@ -117,7 +130,7 @@ function hookOpenWindow (window: BrowserWindow): void {
sandbox: false,
partition: sessionPartition,
nodeIntegration: true,
preload: path.join(app.getAppPath(), 'dist', 'main', 'preload.js'),
preload: preloadScriptPath,
additionalArguments: [
`--open=${encodeURI(
new URL(url).pathname
@@ -127,8 +140,10 @@ function hookOpenWindow (window: BrowserWindow): void {
)}`
]
}
})
await childWindow.loadFile(path.join('dist', 'ui', 'index.html'))
}
setupWindowTitleBar(windowOptions)
const childWindow = new BrowserWindow(windowOptions)
await childWindow.loadFile(containerPagePath)
hookOpenWindow(childWindow)
})()
}
@@ -177,7 +192,7 @@ function handleAuthRedirects (window: BrowserWindow): void {
event.preventDefault()
void (async (): Promise<void> => {
await window.loadFile(path.join('dist', 'ui', 'index.html'))
await window.loadFile(containerPagePath)
window.webContents.send('handle-auth', urlObj.searchParams.get('token'))
})()
}
@@ -211,12 +226,11 @@ function handleWillDownload (window: BrowserWindow): void {
const createWindow = async (): Promise<void> => {
// Restore window position if available
const restoredBounds: any = settings.get('windowBounds')
mainWindow = new BrowserWindow({
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: restoredBounds?.width ?? defaultWidth,
height: restoredBounds?.height ?? defaultHeight,
x: restoredBounds?.x ?? undefined,
y: restoredBounds?.y ?? undefined,
titleBarStyle: isMac ? 'hidden' : 'default',
trafficLightPosition: { x: 10, y: 10 },
roundedCorners: true,
icon: nativeImage.createFromPath(iconKey),
@@ -226,15 +240,17 @@ const createWindow = async (): Promise<void> => {
nodeIntegration: true,
// backgroundThrottling: false,
partition: sessionPartition,
preload: path.join(app.getAppPath(), 'dist', 'main', 'preload.js')
preload: preloadScriptPath
}
})
}
setupWindowTitleBar(windowOptions)
mainWindow = new BrowserWindow(windowOptions)
app.dock?.setIcon(nativeImage.createFromPath(iconKey))
// await mainWindow.webContents.openDevTools()
if (isDev) {
mainWindow.webContents.openDevTools()
}
await mainWindow.loadFile(path.join('dist', 'ui', 'index.html'))
await mainWindow.loadFile(containerPagePath)
addPermissionHandlers(mainWindow.webContents.session)
handleAuthRedirects(mainWindow)
handleWillDownload(mainWindow)
@@ -251,6 +267,14 @@ const createWindow = async (): Promise<void> => {
}
})
mainWindow.on('maximize', () => {
mainWindow?.webContents.send('window-state-changed', 'maximized');
});
mainWindow.on('unmaximize', () => {
mainWindow?.webContents.send('window-state-changed', 'unmaximized');
});
if (isMac) {
mainWindow.on('close', (event) => {
// Prevent the default behavior (which would quit the app)
@@ -265,9 +289,11 @@ const createWindow = async (): Promise<void> => {
}
}
addMenus(() => mainWindow as BrowserWindow, (cmd: string, ...args: any[]) => {
mainWindow?.webContents.send(cmd, ...args)
})
if (false == isWindows) {
addMenus((cmd: string, ...args: any[]) => {
mainWindow?.webContents.send(cmd, ...args)
})
}
contextMenu({
showSaveImageAs: false,
@@ -303,8 +329,8 @@ ipcMain.on('send-notification', (event, notificationParams: NotificationParams)
ipcMain.on('set-title', (event, title) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win?.setTitle(title)
const window = BrowserWindow.fromWebContents(webContents)
window?.setTitle(title)
})
ipcMain.on('set-combined-config', (event, config: Config) => {
@@ -353,6 +379,32 @@ ipcMain.on('set-front-cookie', function (event, host: string, name: string, valu
void win?.webContents?.session.cookies.set(cv)
})
ipcMain.handle('window-minimize', () => {
mainWindow?.minimize();
});
ipcMain.handle('window-maximize', () => {
if (mainWindow) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.handle('window-close', () => {
mainWindow?.close();
});
ipcMain.handle('get-is-os-using-dark-theme', () => {
return nativeTheme.shouldUseDarkColors;
});
ipcMain.handle('menu-action', async (_event: any, action: MenuBarAction) => {
dipatchMenuBarAction(mainWindow, action)
});
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
@@ -440,8 +492,9 @@ autoUpdater.on('download-progress', (progressObj: ProgressInfo) => {
})
function setDownloadProgress (percent: number): void {
if (mainWindow === undefined) return
if (mainWindow === undefined) {
return
}
mainWindow.setProgressBar(percent / 100)
mainWindow.webContents.send('handle-update-download-progress', percent)
}
+225
View File
@@ -1,14 +1,239 @@
<!doctype html>
<html>
<script>
window.desktopPlatform=true
<% if (typeof isWindows !== 'undefined' && isWindows) { %>
window.windowsPlatform = true;
<% } else { %>
window.windowsPlatform = false;
<% } %>
</script>
<head>
<meta charset="utf8">
<% if (typeof isWindows !== 'undefined' && isWindows) { %>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-secondary: #f3f3f3;
--bg-tertiary: #ffffff;
--bg-hover: #e8e8e8;
--bg-separator: #d4d4d4;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #e5e5e5;
--accent-color: #0078d4;
--close-hover: #e81123;
--huly-top-indent: 32px;
--huly-history-box-left-indent: 0;
}
[data-theme="dark"] {
--bg-secondary: #323233;
--bg-tertiary: #252526;
--bg-hover: #2a2d2e;
--bg-separator: #454545;
--text-primary: #cccccc;
--text-secondary: #888888;
--border-color: #2d2d30;
--accent-color: #007acc;
--close-hover: #c42b1c;
}
.desktop-app-titlebar {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--text-primary);
display: flex;
height: 32px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag; /* allows to drag window around */
}
.desktop-app-menu-container {
display: flex;
align-items: center;
-webkit-app-region: no-drag;
}
.desktop-app-menu-bar {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.desktop-app-menu-item {
position: relative;
align-items: normal; /* explicitly set this up to prevent values from above */
}
.desktop-app-top-menu-button {
background: none;
border: none;
color: var(--text-primary);
padding: 6px 8px;
cursor: pointer;
font-size: 13px;
position: relative;
user-select: none;
align-items: normal; /* explicitly set this up to prevent values from above */
}
.desktop-app-top-menu-button:hover {
background-color: var(--bg-hover);
}
.desktop-app-top-menu-button:focus {
outline: 1px solid var(--accent-color);
outline-offset: -1px;
}
.desktop-app-top-menu-button.desktop-app-alt-mode {
background-color: var(--bg-hover);
}
.desktop-app-accelerator {
text-decoration: none;
border-bottom: 1px solid transparent;
align-items: normal; /* explicitly set this up to prevent values from above */
}
.desktop-app-alt-active .desktop-app-accelerator {
border-bottom-color: var(--text-primary);
}
.desktop-app-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background-color: var(--bg-tertiary);
border: 1px solid var(--bg-separator);
min-width: 180px;
display: none;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .desktop-app-dropdown-menu {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.desktop-app-dropdown-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background: none;
border: none;
color: var(--text-primary);
padding: 8px 12px;
text-align: left;
cursor: pointer;
font-size: 13px;
user-select: none;
}
.desktop-app-dropdown-item:hover,
.desktop-app-dropdown-item.desktop-app-keyboard-selected {
background-color: var(--bg-hover);
}
.desktop-app-dropdown-item .desktop-app-shortcut {
color: var(--text-secondary);
font-size: 12px;
margin-left: 20px;
}
.desktop-app-dropdown-item .desktop-app-accelerator {
border-bottom: 1px solid transparent;
}
.desktop-app-alt-active .desktop-app-dropdown-item .desktop-app-accelerator {
border-bottom-color: var(--text-primary);
}
.desktop-app-dropdown-separator {
height: 1px;
background-color: var(--bg-separator);
margin: 4px 0;
}
.desktop-app-window-title {
flex: 1;
text-align: center;
font-size: 13px;
line-height: 32px;
color: var(--text-primary);
user-select: none;
-webkit-app-region: drag;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.desktop-app-window-controls {
display: flex;
-webkit-app-region: no-drag;
}
.desktop-app-control-button {
width: 46px;
height: 32px;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.desktop-app-control-button:hover {
background-color: var(--bg-hover);
}
.desktop-app-control-button.desktop-app-close:hover {
background-color: var(--close-hover);
color: white;
}
</style>
<% } %>
</head>
<body style="margin: 0; overflow: hidden;">
<% if (typeof isWindows !== 'undefined' && isWindows) { %>
<div id="desktop-app-titlebar-root">
<div class="desktop-app-titlebar">
<div class="desktop-app-menu-container">
</div>
<div class="desktop-app-window-title" id="application-title-bar-caption">Huly</div>
<div class="desktop-app-window-controls">
<button class="desktop-app-control-button desktop-app-minimize" id="minimize-button">─</button>
<button class="desktop-app-control-button desktop-app-maximize" id="maximize-button">□</button>
<button class="desktop-app-control-button desktop-app-close" id="close-button">✕</button>
</div>
</div>
</div>
<% } %>
</body>
</html>
+38 -9
View File
@@ -19,20 +19,49 @@ import { workbenchId, logOut } from '@hcengineering/workbench'
import { isOwnerOrMaintainer } from '@hcengineering/core'
import { configurePlatform } from './platform'
import { setupTitleBarMenu } from './titleBarMenu'
import { defineScreenShare, defineGetDisplayMedia } from './screenShare'
import { IPCMainExposed } from './types'
import { StandardMenuCommandLogout, StandardMenuCommandSelectWorkspace, StandardMenuCommandOpenSettings } from './types'
import { ipcMainExposed } from './typesUtils'
import { themeStore } from '@hcengineering/theme'
defineScreenShare()
defineGetDisplayMedia()
void configurePlatform().then(() => {
createApp(document.body)
})
window.addEventListener('DOMContentLoaded', () => {
const ipcMain = (window as any).electron as IPCMainExposed
const ipcMain = ipcMainExposed()
if ((window as any).windowsPlatform === true) {
const titleBarRoot = document.getElementById('desktop-app-titlebar-root')
if (titleBarRoot) {
const menuBar = setupTitleBarMenu(ipcMain, titleBarRoot)
ipcMain.on('open-settings', () => {
themeStore.subscribe((themeOptions) => {
if (themeOptions != null) {
const isDarkTheme = themeOptions.dark
menuBar.setTheme(isDarkTheme ? 'dark' : 'light')
}
})
void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => {
menuBar.setTheme(isDarkTheme ? 'dark' : 'light')
}).catch(() => {
menuBar.setTheme('light'); // fallback
})
}
}
void configurePlatform().then((parameters) => {
const windowTitle = document.getElementById('application-title-bar-caption')
if (windowTitle) {
windowTitle.textContent = parameters.getBranding().getTitle()
}
createApp(document.body)
})
ipcMain.on(StandardMenuCommandOpenSettings, () => {
closePopup()
closePanel()
const loc = getCurrentResolvedLocation()
@@ -43,7 +72,7 @@ window.addEventListener('DOMContentLoaded', () => {
navigate(loc)
})
ipcMain.on('select-workspace', () => {
ipcMain.on(StandardMenuCommandSelectWorkspace, () => {
closePopup()
closePanel()
const loc = getCurrentResolvedLocation()
@@ -55,7 +84,7 @@ window.addEventListener('DOMContentLoaded', () => {
navigate(loc)
})
ipcMain.on('logout', () => {
ipcMain.on(StandardMenuCommandLogout, () => {
void logOut().then(() => {
navigate({ path: [loginId] })
})
+4 -5
View File
@@ -16,8 +16,7 @@ import { activePreferences } from '@hcengineering/desktop-preferences-resources'
import { getDisplayInboxData, InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { inboxId } from '@hcengineering/inbox'
import communication from '@hcengineering/communication'
import { IPCMainExposed } from './types'
import { ipcMainExposed } from './typesUtils'
let client: TxOperations
@@ -126,7 +125,7 @@ export function configureNotifications (): void {
addEventListener(workbench.event.NotifyConnection, async () => {
client = getClient()
const electronAPI: IPCMainExposed = (window as any).electron
const electronAPI = ipcMainExposed()
const inboxClient = InboxNotificationsClientImpl.getClient()
const notificationsQuery = createNotificationsQuery(true)
@@ -247,13 +246,13 @@ export function configureNotifications (): void {
})
addEventListener(workbench.event.NotifyTitle, async (event, title: string) => {
;((window as any).electron as IPCMainExposed).setTitle(title)
ipcMainExposed().setTitle(title)
})
location.subscribe((location) => {
if (!(location.path[0] === workbenchId || location.path[0] === workbench.component.WorkbenchApp)) {
// We need to clear badge
;((window as any).electron as IPCMainExposed).setBadge(0)
ipcMainExposed().setBadge(0)
}
})
}
+22 -3
View File
@@ -137,7 +137,8 @@ import textEditor, { textEditorId } from '@hcengineering/text-editor'
import { initThemeStore, setDefaultLanguage } from '@hcengineering/theme'
import { configureNotifications } from './notifications'
import { configureAnalyticsProviders } from '@hcengineering/analytics-providers'
import { Branding, Config, IPCMainExposed } from './types'
import { Branding, Config, } from './types'
import { ipcMainExposed } from './typesUtils'
import github, { githubId } from '@hcengineering/github'
import '@hcengineering/github-assets'
@@ -247,10 +248,26 @@ function configureI18n (): void {
addStringsLoader(billingId, async (lang: string) => await import(`@hcengineering/billing-assets/lang/${lang}.json`))
}
export async function configurePlatform (): Promise<void> {
export class PlatformBranding {
constructor(private title: string) {
}
public getTitle(): string {
return this.title;
}
}
export class PlatformParameters {
constructor(private branding: PlatformBranding) {
}
public getBranding(): PlatformBranding {
return this.branding;
}
}
export async function configurePlatform (): Promise<PlatformParameters> {
configureI18n()
const ipcMain = (window as any).electron as IPCMainExposed
const ipcMain = ipcMainExposed()
const config: Config = await ipcMain.config()
const myBranding: Branding = await ipcMain.branding()
// await (await fetch(devConfig? '/config-dev.json' : '/config.json')).json()
@@ -459,4 +476,6 @@ export async function configurePlatform (): Promise<void> {
}
console.log('Initial location is: ', getCurrentLocation())
return new PlatformParameters(new PlatformBranding(title))
}
+25 -1
View File
@@ -1,7 +1,7 @@
// preload.js
import { contextBridge, ipcRenderer } from 'electron'
import { BrandingMap, Config, IPCMainExposed, NotificationParams } from './types'
import { BrandingMap, Config, IPCMainExposed, MenuBarAction, NotificationParams } from './types'
/**
* @public
@@ -62,6 +62,30 @@ const expose: IPCMainExposed = {
ipcRenderer.send('send-notification', notificationParams)
},
minimizeWindow: () => {
ipcRenderer.invoke('window-minimize')
},
maximizeWindow: () => {
ipcRenderer.invoke('window-maximize')
},
closeWindow: () => {
ipcRenderer.invoke('window-close')
},
onWindowStateChange: (callback) => {
ipcRenderer.on('window-state-changed', callback)
},
isOsUsingDarkTheme: async () => {
return await ipcRenderer.invoke('get-is-os-using-dark-theme')
},
executeMenuBarAction: (action: MenuBarAction) => {
ipcRenderer.invoke('menu-action', action)
},
config: async () => {
if (configPromise === undefined) {
configPromise = new Promise((resolve, reject) => {
+3 -3
View File
@@ -3,7 +3,7 @@ import love from '@hcengineering/love'
import { setCustomCreateScreenTracks } from '@hcengineering/love-resources'
import { showPopup } from '@hcengineering/ui'
import { Track, LocalTrack, LocalAudioTrack, LocalVideoTrack, ParticipantEvent, TrackInvalidError, ScreenShareCaptureOptions, DeviceUnsupportedError, ScreenSharePresets } from 'livekit-client'
import { IPCMainExposed } from './types'
import { ipcMainExposed } from './typesUtils'
export function defineGetDisplayMedia (): void {
if (navigator?.mediaDevices === undefined) {
@@ -20,7 +20,7 @@ export function defineGetDisplayMedia (): void {
throw new Error('opts must be provided')
}
const ipcMain = (window as any).electron as IPCMainExposed
const ipcMain = ipcMainExposed()
const sources = await ipcMain.getScreenSources()
const hasAccess = await ipcMain.getScreenAccess()
@@ -65,7 +65,7 @@ export function defineGetDisplayMedia (): void {
export function defineScreenShare (): void {
setCustomCreateScreenTracks(async function electronCreateScreenTracks (options?: ScreenShareCaptureOptions) {
const ipcMain = (window as any).electron as IPCMainExposed
const ipcMain = ipcMainExposed()
const sources = await ipcMain.getScreenSources()
const hasAccess = await ipcMain.getScreenAccess()
+525
View File
@@ -0,0 +1,525 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { IPCMainExposed, MenuBarAction } from './types'
import { isMenuBarAction } from './typesUtils'
import { TitleBarMenuState } from './titleBarMenuState'
export function setupTitleBarMenu(ipcMain: IPCMainExposed, root: HTMLElement): MenuBar {
const themeManager = new ThemeManager('light')
const menuManager = new MenuBarManager(ipcMain, root)
const menuBar = menuManager.getView();
const menuContainer = root.querySelector('.desktop-app-menu-container')
if (menuContainer) {
const existingMenuBar = menuContainer.querySelector('.desktop-app-menu-bar')
if (existingMenuBar) {
existingMenuBar.remove()
}
menuContainer.appendChild(menuBar)
}
menuManager.attachEventListeners(ipcMain)
ipcMain.onWindowStateChange((_event, state) => {
const maximizeButton = root.querySelector('#maximize-button')
if (maximizeButton) {
if (state === 'maximized') {
maximizeButton.textContent = '❐'
} else {
maximizeButton.textContent = '□'
}
}
})
return new MenuBar(themeManager)
}
type TitleBarTheme = 'dark' | 'light';
export class MenuBar {
constructor(private readonly theme: ThemeManager) {
}
public setTheme(theme: TitleBarTheme): void {
this.theme.setTheme(theme)
}
}
export function buildHulyApplicationMenu(): HTMLElement {
const menuBuilder = new MenuBuilder()
const MenuFileIndex = 0
menuBuilder.addTopLevelMenu('File', 'f')
.addMenuItem(MenuFileIndex, 'Settings', 'settings', undefined, 's')
.addMenuItem(MenuFileIndex, 'Select Workspace', 'select-workspace', undefined, 'w')
.addMenuItem(MenuFileIndex, 'Logout', 'logout', undefined, 'l')
.addSeparator(MenuFileIndex)
.addMenuItem(MenuFileIndex, 'Exit', 'exit', 'Alt+F4', 'x')
const MenuEditIndex = 1
menuBuilder.addTopLevelMenu('Edit', 'e')
.addMenuItem(MenuEditIndex, 'Undo', 'undo', 'Ctrl+Z', 'u')
.addMenuItem(MenuEditIndex, 'Redo', 'redo', 'Ctrl+Y', 'r')
.addSeparator(MenuEditIndex)
.addMenuItem(MenuEditIndex, 'Cut', 'cut', 'Ctrl+X', 't')
.addMenuItem(MenuEditIndex, 'Copy', 'copy', 'Ctrl+C', 'c')
.addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p')
.addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd')
.addSeparator(MenuEditIndex)
.addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a')
const MenuViewIndex = 2
menuBuilder.addTopLevelMenu('View', 'v')
.addMenuItem(MenuViewIndex, 'Reload', 'reload', 'Ctrl+R', 'r')
.addMenuItem(MenuViewIndex, 'Force Reload', 'force-reload', 'Ctrl+Shift+R', 'o')
.addMenuItem(MenuViewIndex, 'Toggle Developer Tools', 'toggle-devtools', 'Ctrl+Shift+I', 'd')
.addSeparator(MenuViewIndex)
.addMenuItem(MenuViewIndex, 'Zoom In', 'zoom-in', 'Ctrl+\'+\'', 'i')
.addMenuItem(MenuViewIndex, 'Zoom Out', 'zoom-out', 'Ctrl+\'-\'', 'u')
.addMenuItem(MenuViewIndex, 'Actual Size', 'restore-size', 'Ctrl+0', 'a')
.addSeparator(MenuViewIndex)
.addMenuItem(MenuViewIndex, 'Toggle Fullscreen', 'toggle-fullscreen', 'F11', 'l')
return menuBuilder.build()
}
class ThemeManager {
private readonly domThemeKey = 'data-theme'
constructor(theme: TitleBarTheme) {
this.setTheme(theme)
}
public setTheme(theme: TitleBarTheme): void {
document.body.setAttribute(this.domThemeKey, theme)
}
}
interface MenuItem {
type: 'item' | 'separator'
label?: string
action?: MenuBarAction
shortcut?: string
acceleratorChar?: string
}
interface TopLevelMenu {
label: string
accelerator: string
subMenus: MenuItem[]
}
export class MenuBuilder {
private menus: TopLevelMenu[] = []
public addTopLevelMenu(label: string, accelerator: string): this {
const menu: TopLevelMenu = {
label,
accelerator: accelerator.toLowerCase(),
subMenus: []
}
this.menus.push(menu)
return this
}
public addMenuItem(
topLevelMenuIndex: number,
label: string,
action: MenuBarAction,
shortcut?: string,
acceleratorChar: string | null = null
): this {
if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) {
const item: MenuItem = {
type: 'item',
label,
action,
shortcut,
acceleratorChar: (acceleratorChar || label.charAt(0)).toLowerCase(),
}
this.menus[topLevelMenuIndex].subMenus.push(item)
}
return this
}
public addSeparator(topLevelMenuIndex: number): this {
if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) {
this.menus[topLevelMenuIndex].subMenus.push({ type: 'separator' })
}
return this
}
public build(): HTMLElement {
const menuBar = document.createElement('ul')
menuBar.className = 'desktop-app-menu-bar'
this.menus.forEach((topLevelMenu) => {
const topLevelMenuView = document.createElement('li')
topLevelMenuView.className = 'desktop-app-menu-item'
const menuButton = document.createElement('button')
menuButton.className = 'desktop-app-top-menu-button'
menuButton.dataset.menu = topLevelMenu.label.toLowerCase()
menuButton.dataset.accelerator = topLevelMenu.accelerator
const acceleratorSpan = document.createElement('span')
acceleratorSpan.className = 'desktop-app-accelerator'
acceleratorSpan.dataset.menu = topLevelMenu.label.toLowerCase()
const labelText = topLevelMenu.label
const acceleratorIndex = labelText.toLowerCase().indexOf(topLevelMenu.accelerator)
if (acceleratorIndex === 0) {
acceleratorSpan.textContent = topLevelMenu.accelerator.toUpperCase()
menuButton.appendChild(acceleratorSpan)
menuButton.appendChild(document.createTextNode(labelText.substring(1)))
} else if (acceleratorIndex > 0) {
acceleratorSpan.textContent = topLevelMenu.accelerator.toLowerCase()
menuButton.appendChild(document.createTextNode(labelText.substring(0, acceleratorIndex)))
menuButton.appendChild(acceleratorSpan)
menuButton.appendChild(document.createTextNode(labelText.substring(acceleratorIndex + 1)))
} else {
menuButton.textContent = labelText
}
const dropdown = document.createElement('div')
dropdown.className = 'desktop-app-dropdown-menu'
dropdown.id = `${topLevelMenu.label.toLowerCase()}-menu`
topLevelMenu.subMenus.forEach(item => {
if (item.type === 'separator') {
const separator = document.createElement('div')
separator.className = 'desktop-app-dropdown-separator'
dropdown.appendChild(separator)
} else if (item.type === 'item' && item.label != null) {
const menuItemButton = document.createElement('button')
menuItemButton.className = 'desktop-app-dropdown-item'
menuItemButton.dataset.accelerator = item.acceleratorChar
menuItemButton.dataset.action = item.action
const labelSpan = document.createElement('span')
const itemAcceleratorSpan = document.createElement('span')
itemAcceleratorSpan.className = 'desktop-app-accelerator'
if (item.acceleratorChar) {
const labelParts = this.splitLabelByAccelerator(item.label, item.acceleratorChar)
const actualChar = item.label.charAt(
item.label.toLowerCase().indexOf(item.acceleratorChar.toLowerCase())
)
itemAcceleratorSpan.textContent = actualChar
if (labelParts.before) {
labelSpan.appendChild(document.createTextNode(labelParts.before))
}
labelSpan.appendChild(itemAcceleratorSpan)
if (labelParts.after) {
labelSpan.appendChild(document.createTextNode(labelParts.after))
}
}
menuItemButton.appendChild(labelSpan)
if (item.shortcut) {
const shortcutSpan = document.createElement('span')
shortcutSpan.className = 'desktop-app-shortcut'
shortcutSpan.textContent = item.shortcut
menuItemButton.appendChild(shortcutSpan)
}
dropdown.appendChild(menuItemButton)
}
})
topLevelMenuView.appendChild(menuButton)
topLevelMenuView.appendChild(dropdown)
menuBar.appendChild(topLevelMenuView)
})
return menuBar
}
private splitLabelByAccelerator(label: string, acceleratorChar: string): { before: string; after: string } {
const index = label.toLowerCase().indexOf(acceleratorChar.toLowerCase())
if (index === -1) {
return { before: label, after: '' }
}
return {
before: label.substring(0, index),
after: label.substring(index + 1)
}
}
}
class MenuBarManager {
private readonly state: TitleBarMenuState
private readonly view: HTMLElement
private readonly TopMenuStyle = '.desktop-app-top-menu-button'
private readonly DropdownMenuStyle = '.desktop-app-dropdown-menu'
private readonly DropdownItemStyle = '.desktop-app-dropdown-item'
private readonly MenuItemStyle = '.desktop-app-menu-item'
private readonly StateStyleAltMode = 'desktop-app-alt-mode'
private readonly StateStyleKeyboardSelected = 'desktop-app-keyboard-selected'
private readonly StateStyleAltModeActive = 'desktop-app-alt-active'
constructor(ipcMain: IPCMainExposed, private readonly root: HTMLElement) {
this.state = new TitleBarMenuState(
() => this.topLevelMenus().length,
(topLevelMenuIndex: number) => {
const children = this.childrenOfTopLevelMenu(topLevelMenuIndex)
return children.length
}
)
this.view = buildHulyApplicationMenu()
}
public getView(): HTMLElement {
return this.view
}
private topLevelMenus() {
return this.root.querySelectorAll<HTMLButtonElement>(this.TopMenuStyle)
}
private onButtonClick(id: string, callback: () => void): void {
const button = this.root.querySelector(`#${id}`)
if (button) {
button.addEventListener('click', callback)
}
}
public attachEventListeners(ipcMain: IPCMainExposed): void {
this.onButtonClick('minimize-button', () => {
ipcMain.minimizeWindow()
})
this.onButtonClick('maximize-button', () => {
ipcMain.maximizeWindow()
})
this.onButtonClick('close-button', () => {
ipcMain.closeWindow()
})
document.addEventListener('keydown', (e) => this.handleKeyDown(ipcMain, e))
document.addEventListener('keyup', (e) => this.handleKeyUp(e))
this.topLevelMenus().forEach((button, index) => {
button.addEventListener('click', (e) => this.handleTopLevelMenuButtonClick(e, index))
})
document.addEventListener('click', (e) => this.handleDocumentClick(e))
document.querySelectorAll<HTMLButtonElement>(this.DropdownItemStyle + '[data-action]').forEach(item => {
item.addEventListener('click', async () => {
const action = item.dataset.action
if (action) {
if (isMenuBarAction(action)) {
await this.executeMenuAction(ipcMain, action)
}
this.state.closeAll()
this.renderState()
}
})
})
}
private renderState() {
if (this.state.isAltModeActive) {
this.root.classList.add(this.StateStyleAltModeActive)
} else {
this.root.classList.remove(this.StateStyleAltModeActive)
}
this.root.querySelectorAll<HTMLElement>(this.DropdownMenuStyle).forEach(menu => {
menu.style.display = 'none'
})
const topLevelMenus = this.topLevelMenus()
topLevelMenus.forEach((button, index) => {
button.classList.remove(this.StateStyleAltMode)
if (index === this.state.FocusedTopLevelMenuIndex) {
button.classList.add(this.StateStyleAltMode)
button.focus()
if (this.state.isTopLevelMenuExpanded && button.dataset.menu) {
const menuType = button.dataset.menu
const dropdown = this.root.querySelector(`#${menuType}-menu`) as HTMLElement | null
if (dropdown) {
dropdown.style.display = 'block'
}
}
} else {
button.blur()
}
})
if (this.state.FocusedTopLevelMenuIndex != null && this.state.isTopLevelMenuExpanded) {
const candidates = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex)
candidates.forEach((menu, index) => {
if (index === this.state.FocusedChildMenuIndex) {
menu.classList.add(this.StateStyleKeyboardSelected)
} else {
menu.classList.remove(this.StateStyleKeyboardSelected)
}
})
}
}
private childrenOfTopLevelMenu(index: number): NodeListOf<HTMLElement> {
const topLevelMenus = this.topLevelMenus()
const menuButton = topLevelMenus[index]
const menuType = menuButton.dataset.menu
const dropdown = this.root.querySelector(`#${menuType}-menu`) as HTMLElement | null
if (dropdown) {
return dropdown.querySelectorAll<HTMLElement>(this.DropdownItemStyle)
}
return document.createDocumentFragment().querySelectorAll<HTMLElement>('*')
}
private async executeMenuAction(ipcMain: IPCMainExposed, action: MenuBarAction): Promise<void> {
try {
await ipcMain.executeMenuBarAction(action);
} catch (error) {
console.error('error executing action:', error)
}
}
private handleKeyDown(ipcMain: IPCMainExposed, e: KeyboardEvent): void {
if (e.altKey) {
if (this.state.isAltModeActive) {
if (this.state.FocusedTopLevelMenuIndex != null) {
this.state.closeAll()
this.renderState()
return
}
} else {
this.state.enterAltMode(null)
this.renderState()
}
}
switch (e.key) {
case 'ArrowLeft':
this.state.moveFocusHorizontal(-1)
this.renderState()
break
case 'ArrowRight':
this.state.moveFocusHorizontal(+1)
this.renderState()
break
case 'ArrowDown':
this.state.moveFocusVertical(+1)
this.renderState()
break
case 'ArrowUp':
this.state.moveFocusVertical(-1)
this.renderState()
break
}
switch (e.key) {
case 'Escape':
this.state.defocus()
this.renderState()
break
case 'Enter':
case ' ':
if (this.state.FocusedTopLevelMenuIndex != null){
if (this.state.isTopLevelMenuExpanded) {
if (this.state.FocusedChildMenuIndex != null) {
const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex)
const focused = children[this.state.FocusedChildMenuIndex]
this.state.closeAll()
this.renderState()
if (focused.dataset.action && isMenuBarAction(focused.dataset.action)) {
this.executeMenuAction(ipcMain, focused.dataset.action)
}
this.renderState()
}
}
}
break
default:
const key = e.key.toLowerCase()
if (false == this.state.isAltModeActive) {
return
}
if (this.state.isTopLevelMenuExpanded && this.state.FocusedTopLevelMenuIndex != null) {
const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex)
for (let i = 0; i < children.length; i++) {
if (children[i].dataset.accelerator === key) {
const action = children[i].dataset.action
if (action) {
if (isMenuBarAction(action)) {
this.executeMenuAction(ipcMain, action)
}
this.state.closeAll()
this.renderState()
return
}
}
}
}
const menuButtons = this.root.querySelectorAll<HTMLButtonElement>(this.TopMenuStyle + '[data-accelerator]')
for (let i = 0; i < menuButtons.length; i++) {
if (menuButtons[i].dataset.accelerator === key) {
this.state.expandTopLevelMenu(i)
this.state.focusChildMenu()
this.renderState()
return
}
}
break
}
}
private handleKeyUp(e: KeyboardEvent): void {
if (e.key === 'Alt') {
if (this.state.FocusedTopLevelMenuIndex == null) {
this.state.enterAltMode(0)
this.renderState()
}
}
}
private handleTopLevelMenuButtonClick(e: Event, index: number): void {
this.state.expandTopLevelMenu(index)
this.renderState()
}
private handleDocumentClick(e: Event): void {
const target = e.target as HTMLElement
if (!target.closest(this.MenuItemStyle)) {
this.state.closeAll()
this.renderState()
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export class TitleBarMenuState {
private altModeActive: boolean = false
private topLevelMenuExpanded: boolean = false
private focusedTopLevelMenuIndex: number | null = null
private focusedChildMenuIndex: number | null = null
private readonly topLevelMenuCount: () => number;
private readonly menuItemsCount: (topLevelMenuIndex: number) => number;
public constructor(
topLevelMenuCount: () => number,
menuItemsCount: (topLevelMenuIndex: number) => number
) {
this.topLevelMenuCount = topLevelMenuCount
this.menuItemsCount = menuItemsCount
}
public focusChildMenu() {
if (null != this.focusedTopLevelMenuIndex && this.topLevelMenuExpanded) {
this.focusedChildMenuIndex = 0
}
}
public expandTopLevelMenu(topLevelMenuIndex: number): void {
if (0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) {
return
}
if (this.focusedTopLevelMenuIndex === topLevelMenuIndex && this.topLevelMenuExpanded) {
this.closeAll()
} else {
this.focusedTopLevelMenuIndex = topLevelMenuIndex
this.topLevelMenuExpanded = true
}
}
public enterAltMode(topLevelMenuIndex: number | null): void {
this.altModeActive = true
if (null == topLevelMenuIndex || 0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) {
return
}
this.focusedTopLevelMenuIndex = topLevelMenuIndex
}
public closeAll(): void {
this.altModeActive = false
this.topLevelMenuExpanded = false
this.focusedTopLevelMenuIndex = null
this.focusedChildMenuIndex = null
}
public defocus(): void {
if (this.topLevelMenuExpanded) {
this.topLevelMenuExpanded = false
this.focusedChildMenuIndex = null
} else {
this.closeAll();
}
}
public moveFocusHorizontal(increment: number): void {
if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) {
return;
}
const topLevelMenuCount = this.topLevelMenuCount()
this.focusedTopLevelMenuIndex = (this.focusedTopLevelMenuIndex + topLevelMenuCount + increment) % topLevelMenuCount
if (this.topLevelMenuExpanded) {
this.focusedChildMenuIndex = 0
}
}
public moveFocusVertical(increment: number): void {
if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) {
return;
}
const menuItemsCount = this.menuItemsCount(this.focusedTopLevelMenuIndex)
this.topLevelMenuExpanded = true
if (menuItemsCount === 0) {
return;
}
this.focusedChildMenuIndex = ((this.focusedChildMenuIndex ?? -1) + menuItemsCount + increment) % menuItemsCount
}
public get isAltModeActive(): boolean {
return this.altModeActive
}
public get isTopLevelMenuExpanded(): boolean {
return this.topLevelMenuExpanded
}
public get FocusedTopLevelMenuIndex(): number | null {
return this.focusedTopLevelMenuIndex
}
public get FocusedChildMenuIndex(): number | null {
return this.focusedChildMenuIndex
}
}
+18 -9
View File
@@ -1,10 +1,8 @@
import { DownloadItem } from '@hcengineering/desktop-downloads'
import { ScreenSource } from '@hcengineering/love'
import { Plugin } from '@hcengineering/platform'
import { IpcRendererEvent } from 'electron'
/**
* @public
*/
export interface Config {
ACCOUNTS_URL: string
AI_URL?: string
@@ -75,9 +73,11 @@ export interface Branding {
export type BrandingMap = Record<string, Branding>
/**
* @public
*/
export const StandardMenuCommandOpenSettings = 'open-settings' as const
export const StandardMenuCommandSelectWorkspace = 'select-workspace' as const
export const StandardMenuCommandLogout = 'logout' as const
export type StandardMenuCommand = typeof StandardMenuCommandOpenSettings | typeof StandardMenuCommandSelectWorkspace | typeof StandardMenuCommandLogout
export interface NotificationParams {
title: string
body: string
@@ -85,9 +85,11 @@ export interface NotificationParams {
application: Plugin
}
/**
* @public
*/
export const MenuBarActions = ['settings', 'select-workspace', 'logout', 'exit', 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'reload', 'force-reload', 'toggle-devtools'
, 'zoom-in', 'zoom-out', 'restore-size', 'toggle-fullscreen'] as const;
export type MenuBarAction = typeof MenuBarActions[number];
export interface IPCMainExposed {
setBadge: (badge: number) => void
setTitle: (title: string) => void
@@ -107,4 +109,11 @@ export interface IPCMainExposed {
cancelBackup: () => void
startBackup: (token: string, endpoint: string, workspace: string) => void
minimizeWindow: () => void
maximizeWindow: () => void
closeWindow: () => void
onWindowStateChange: (callback: (event: IpcRendererEvent, newState: string) => void) => void
isOsUsingDarkTheme: () => Promise<boolean>
executeMenuBarAction: (action: MenuBarAction) => void
}
+24
View File
@@ -0,0 +1,24 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { IPCMainExposed, MenuBarAction, MenuBarActions } from './types'
export function ipcMainExposed(): IPCMainExposed {
return (window as any).electron as IPCMainExposed
}
export function isMenuBarAction(value: string): value is MenuBarAction {
return MenuBarActions.includes(value as MenuBarAction);
}
+11
View File
@@ -318,6 +318,17 @@ module.exports = [
},
publicPath: ''
}),
new HtmlWebpackPlugin({
template: './src/ui/index.ejs',
filename: 'index.windows.html',
meta: {
viewport: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=1'
},
publicPath: '',
templateParameters: {
isWindows: true
}
}),
...(!dev ? [new CompressionPlugin()] : []),
// new MiniCssExtractPlugin({
// filename: '[name].[id][contenthash].css'
+9 -6
View File
@@ -77,8 +77,6 @@
scrollbar-color: var(--theme-navpanel-divider) transparent;
scrollbar-width: none;
--body-font-size: .875rem;
--status-bar-height: 36px;
--status-bar-normal-height: 36px;
--panel-aside-width: 25rem; // 20rem;
--font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto;
--mono-font: 'IBM Plex Mono', monospace;
@@ -90,10 +88,6 @@
&::after,
&::before { box-sizing: border-box; }
@media (max-width: 480px) {
--status-bar-height: 70px;
}
}
:root {
.plyr__volume {
@@ -161,6 +155,15 @@ body {
color: var(--content-color);
background-color: var(--theme-bg-color);
user-select: none;
--status-bar-height: 36px;
--status-bar-normal-height: 36px;
@media (max-width: 480px) {
&:has(.workbench-container) {
--status-bar-height: 70px;
}
}
}
html,
+18 -15
View File
@@ -303,8 +303,8 @@
display: flex;
flex-direction: column;
// height: 100vh;
height: 100%;
height: 100dvh;
height: calc(100% - var(--huly-top-indent, 0rem));
height: calc(100dvh - var(--huly-top-indent, 0rem));
// height: var(--app-height);
.antiStatusBar {
@@ -316,11 +316,11 @@
font-size: 0.75rem;
line-height: 150%;
background-color: var(--theme-statusbar-color);
// border-bottom: 1px solid var(--theme-navpanel-divider);
z-index: 1;
.history-box {
-webkit-app-region: no-drag;
margin-left: 4.625rem;
margin-left: var(--huly-history-box-left-indent, 4.625rem);
}
.maintenanceScheduled {
padding: 0 0.5rem;
@@ -346,17 +346,6 @@
display: none;
}
@media (max-width: 480px) {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0;
width: 100%;
.second-row {
display: flex;
}
}
@media print {
display: none;
}
@@ -380,4 +369,18 @@
.left-items {
overflow-x: auto;
}
@media (max-width: 480px) {
#ui-root:has(.workbench-container) .antiStatusBar {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0;
width: 100%;
.second-row {
display: flex;
}
}
}
</style>
@@ -142,7 +142,7 @@
<div
style:position="fixed"
style:left={$deviceInfo.docWidth <= 480 ? '.75rem' : '1.75rem'}
style:top={'3rem'}
style:top={'calc(3rem + var(--huly-top-indent, 0rem))'}
class="flex-row-center"
>
<LoginIcon /><span class="fs-title ml-2">{getMetadata(workbench.metadata.PlatformTitle)}</span>