Start adding editor, fix up sidebar styling
This commit is contained in:
18
angular.json
18
angular.json
@@ -18,18 +18,21 @@
|
|||||||
"outputPath": "dist/proto-creator",
|
"outputPath": "dist/proto-creator",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"polyfills": [
|
"polyfills": [],
|
||||||
],
|
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"assets": ["src/assets"],
|
"assets": ["src/assets"],
|
||||||
"styles": ["src/styles.scss"]
|
"styles": [
|
||||||
|
"src/styles.scss",
|
||||||
|
"highlight.js/styles/atom-one-dark.min.css"
|
||||||
|
],
|
||||||
|
"allowedCommonJsDependencies": ["protobufjs"]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kb",
|
"maximumWarning": "700kb",
|
||||||
"maximumError": "1mb"
|
"maximumError": "1mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,8 +72,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": [
|
"polyfills": [],
|
||||||
],
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
@@ -79,9 +81,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": ["src/styles.scss"],
|
||||||
"src/styles.scss"
|
|
||||||
],
|
|
||||||
"scripts": []
|
"scripts": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
|
"test": "ng test",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"@angular/platform-browser-dynamic": "^18.0.0",
|
"@angular/platform-browser-dynamic": "^18.0.0",
|
||||||
"@angular/router": "^18.0.0",
|
"@angular/router": "^18.0.0",
|
||||||
"@tauri-apps/api": "^1.5.6",
|
"@tauri-apps/api": "^1.5.6",
|
||||||
|
"highlight.js": "^11.9.0",
|
||||||
"protobufjs": "~7.3.0",
|
"protobufjs": "~7.3.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fn build_menu(app_name: &str) -> Menu {
|
|||||||
}
|
}
|
||||||
let open_folder =
|
let open_folder =
|
||||||
CustomMenuItem::new("openfolder".to_string(), "Open Folder").accelerator("cmd+shift+O");
|
CustomMenuItem::new("openfolder".to_string(), "Open Folder").accelerator("cmd+shift+O");
|
||||||
let file_submenu = Menu::new()
|
let mut file_submenu = Menu::new()
|
||||||
.add_item(open_folder)
|
.add_item(open_folder)
|
||||||
.add_native_item(MenuItem::CloseWindow);
|
.add_native_item(MenuItem::CloseWindow);
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
|||||||
@@ -1,12 +1,42 @@
|
|||||||
<mat-toolbar data-tauri-drag-region color="secondary"
|
<mat-toolbar data-tauri-drag-region color="secondary">
|
||||||
><h1>BufPiv</h1></mat-toolbar
|
<h1>BufPiv</h1>
|
||||||
>
|
<span>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="leftSideOpen.set(!leftSideOpen())"
|
||||||
|
title="Toggle left drawer"
|
||||||
|
>
|
||||||
|
<mat-icon [class.filled]="leftSideOpen()">dock_to_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="rightSideOpen.set(!rightSideOpen())"
|
||||||
|
title="Toggle right drawer"
|
||||||
|
>
|
||||||
|
<mat-icon [class.filled]="rightSideOpen()">dock_to_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</mat-toolbar>
|
||||||
<mat-sidenav-container autosize>
|
<mat-sidenav-container autosize>
|
||||||
@if(selectedDirectory()) {
|
@if(selectedDirectory()) {
|
||||||
<mat-sidenav mode="side" opened>
|
<mat-sidenav
|
||||||
|
[mode]="isMobile() ? 'over' : 'side'"
|
||||||
|
[opened]="leftSideOpen()"
|
||||||
|
(closed)="leftSideOpen.set(false)"
|
||||||
|
>
|
||||||
<app-file-tree [files]="files()"></app-file-tree>
|
<app-file-tree [files]="files()"></app-file-tree>
|
||||||
</mat-sidenav>
|
</mat-sidenav>
|
||||||
}
|
}
|
||||||
|
<mat-sidenav
|
||||||
|
[opened]="rightSideOpen()"
|
||||||
|
[mode]="isMobile() ? 'over' : 'side'"
|
||||||
|
position="end"
|
||||||
|
(closed)="rightSideOpen.set(false)"
|
||||||
|
>
|
||||||
|
<app-proto-definition-selector
|
||||||
|
(messageSelected)="selectMessage($event)"
|
||||||
|
></app-proto-definition-selector>
|
||||||
|
</mat-sidenav>
|
||||||
<mat-sidenav-content>
|
<mat-sidenav-content>
|
||||||
@if (!selectedDirectory()) {
|
@if (!selectedDirectory()) {
|
||||||
<div
|
<div
|
||||||
@@ -21,7 +51,8 @@
|
|||||||
Open Folder
|
Open Folder
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}@else { }
|
}@else {
|
||||||
|
<app-editor [selectedMessage]="selectedMessage()"></app-editor>
|
||||||
|
}
|
||||||
</mat-sidenav-content>
|
</mat-sidenav-content>
|
||||||
<!-- TODO: Proto selector (add/remove proto definitinos, select definition for current file) on right side -->
|
|
||||||
</mat-sidenav-container>
|
</mat-sidenav-container>
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ mat-toolbar {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
--mat-toolbar-standard-height: 70px;
|
--mat-toolbar-standard-height: 70px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
mat-sidenav-container {
|
mat-sidenav-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-sidenav.mat-drawer-end {
|
||||||
|
padding: var(--mat-sidenav-container-shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-icon.filled {
|
||||||
|
font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
|
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
import { RouterOutlet } from '@angular/router';
|
|
||||||
import { message, open } from '@tauri-apps/api/dialog';
|
|
||||||
import { FileEntry, readDir } from '@tauri-apps/api/fs';
|
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { MatTreeModule } from '@angular/material/tree';
|
import { MatTreeModule } from '@angular/material/tree';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { message, open } from '@tauri-apps/api/dialog';
|
||||||
|
import { UnlistenFn, listen } from '@tauri-apps/api/event';
|
||||||
|
import { FileEntry, readDir } from '@tauri-apps/api/fs';
|
||||||
|
import { EditorComponent } from './editor/editor.component';
|
||||||
import {
|
import {
|
||||||
FileOrFolder,
|
FileOrFolder,
|
||||||
FileTreeComponent,
|
FileTreeComponent,
|
||||||
} from './file-tree/file-tree.component';
|
} from './file-tree/file-tree.component';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
|
||||||
import { OpenFolderMessage } from './messages/openfolder.message';
|
import { OpenFolderMessage } from './messages/openfolder.message';
|
||||||
|
import { ProtoMessage } from './model/proto-message.model';
|
||||||
|
import { ProtoDefinitionSelectorComponent } from './proto-definition-selector/proto-definition-selector.component';
|
||||||
|
const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -29,14 +35,30 @@ import { OpenFolderMessage } from './messages/openfolder.message';
|
|||||||
MatTreeModule,
|
MatTreeModule,
|
||||||
FileTreeComponent,
|
FileTreeComponent,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
EditorComponent,
|
||||||
|
ProtoDefinitionSelectorComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
protected selectedDirectory = signal<string | null>(null);
|
protected selectedDirectory = signal<string | null>(null);
|
||||||
protected files = signal<FileOrFolder[]>([]);
|
protected files = signal<FileOrFolder[]>([]);
|
||||||
|
protected selectedMessage = signal<ProtoMessage | undefined>(undefined);
|
||||||
|
protected rightSideOpen = signal(true);
|
||||||
|
protected leftSideOpen = signal(true);
|
||||||
|
|
||||||
private unlisten?: UnlistenFn;
|
private unlisten?: UnlistenFn;
|
||||||
|
|
||||||
|
protected isMobile = signal(
|
||||||
|
this.breakpointObserver.isMatched(mobileBreakpoints)
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private breakpointObserver: BreakpointObserver) {
|
||||||
|
breakpointObserver
|
||||||
|
.observe(mobileBreakpoints)
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((matches) => this.isMobile.set(matches.matches));
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.unlisten = await listen(
|
this.unlisten = await listen(
|
||||||
'openfolder',
|
'openfolder',
|
||||||
@@ -83,9 +105,14 @@ export class AppComponent {
|
|||||||
children: entry.children
|
children: entry.children
|
||||||
?.sort(this.sortFiles(splitNumbers))
|
?.sort(this.sortFiles(splitNumbers))
|
||||||
.map((entry) => this.mapEntry(entry, splitNumbers)),
|
.map((entry) => this.mapEntry(entry, splitNumbers)),
|
||||||
|
path: entry.path,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortFiles = (splitNumbers: RegExp) => (a: FileEntry, b: FileEntry) =>
|
private sortFiles = (splitNumbers: RegExp) => (a: FileEntry, b: FileEntry) =>
|
||||||
a.name?.localeCompare(b.name ?? '') ?? 0;
|
a.name?.localeCompare(b.name ?? '') ?? 0;
|
||||||
|
|
||||||
|
protected selectMessage(message: ProtoMessage) {
|
||||||
|
this.selectedMessage.set(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
APP_INITIALIZER,
|
||||||
ApplicationConfig,
|
ApplicationConfig,
|
||||||
provideExperimentalZonelessChangeDetection,
|
provideExperimentalZonelessChangeDetection,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -6,11 +7,39 @@ import { provideRouter } from '@angular/router';
|
|||||||
|
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
import {
|
||||||
|
MAT_ICON_DEFAULT_OPTIONS,
|
||||||
|
MatIconRegistry,
|
||||||
|
} from '@angular/material/icon';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideExperimentalZonelessChangeDetection(),
|
provideExperimentalZonelessChangeDetection(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useValue: () => {
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
},
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (iconRegistry: MatIconRegistry) => () => {
|
||||||
|
iconRegistry.registerFontClassAlias(
|
||||||
|
'material-symbols-rounded',
|
||||||
|
'material-symbols-rounded'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deps: [MatIconRegistry],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MAT_ICON_DEFAULT_OPTIONS,
|
||||||
|
useValue: { fontSet: 'material-symbols-rounded' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
<textarea [(ngModel)]="fileContents" disabled></textarea>
|
<!-- <div>@for (item of items; track $index) {} @switch () {}</div> -->
|
||||||
|
|
||||||
|
<pre><code class="language-json" #code>{{fileContents()}}</code></pre>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, input } from '@angular/core';
|
import { Component, ElementRef, effect, input, viewChild } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { readTextFile } from '@tauri-apps/api/fs';
|
import hljs from 'highlight.js/lib/core';
|
||||||
import { parse } from 'protobufjs';
|
import { ProtoMessage } from '../model/proto-message.model';
|
||||||
|
import { ProtoDefinitionService } from '../proto-definition.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-editor',
|
selector: 'app-editor',
|
||||||
@@ -13,18 +14,15 @@ import { parse } from 'protobufjs';
|
|||||||
})
|
})
|
||||||
export class EditorComponent {
|
export class EditorComponent {
|
||||||
fileContents = input<string>();
|
fileContents = input<string>();
|
||||||
selectedProtoPath = input<string>();
|
selectedMessage = input<ProtoMessage>();
|
||||||
|
private code = viewChild<ElementRef<HTMLElement>>('code');
|
||||||
|
|
||||||
async loadProtoDefinition() {
|
constructor(private protoDefinitionService: ProtoDefinitionService) {
|
||||||
try {
|
effect(() => {
|
||||||
const protoPath = this.selectedProtoPath();
|
const element = this.code()?.nativeElement;
|
||||||
if (protoPath) {
|
if (element) {
|
||||||
const protoContents = await readTextFile(protoPath);
|
hljs.highlightElement(element);
|
||||||
const definition = await parse(protoContents);
|
|
||||||
console.log(definition);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
23
src/app/file-tree/file-tree.component.html
Normal file
23
src/app/file-tree/file-tree.component.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<mat-tree [dataSource]="dataSource()" [treeControl]="treeControl">
|
||||||
|
<mat-tree-node
|
||||||
|
*matTreeNodeDef="let node"
|
||||||
|
matTreeNodePadding
|
||||||
|
(click)="fileSelected.emit(node.file)"
|
||||||
|
>
|
||||||
|
<button mat-icon-button disabled></button>
|
||||||
|
{{ node.file.name }}
|
||||||
|
</mat-tree-node>
|
||||||
|
<mat-tree-node *matTreeNodeDef="let node; when: hasChild" matTreeNodeToggle>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matTreeNodePadding
|
||||||
|
[attr.aria-label]="'Toggle ' + node.file.name"
|
||||||
|
>
|
||||||
|
<mat-icon class="mat-icon-rtl-mirror">
|
||||||
|
@if (treeControl.isExpanded(node)) { expand_more } @else { chevron_right
|
||||||
|
}
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
{{ node.file.name }}
|
||||||
|
</mat-tree-node>
|
||||||
|
</mat-tree>
|
||||||
@@ -19,6 +19,7 @@ export interface FileOrFolder {
|
|||||||
isDirectory: boolean;
|
isDirectory: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
children?: FileOrFolder[];
|
children?: FileOrFolder[];
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileNode {
|
interface FileNode {
|
||||||
@@ -31,29 +32,7 @@ interface FileNode {
|
|||||||
selector: 'app-file-tree',
|
selector: 'app-file-tree',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, MatTreeModule, MatIconModule, MatButtonModule],
|
imports: [CommonModule, MatTreeModule, MatIconModule, MatButtonModule],
|
||||||
template: `<mat-tree [dataSource]="dataSource()" [treeControl]="treeControl">
|
templateUrl: './file-tree.component.html',
|
||||||
<mat-tree-node
|
|
||||||
*matTreeNodeDef="let node"
|
|
||||||
matTreeNodePadding
|
|
||||||
(click)="fileSelected.emit(node.file)"
|
|
||||||
>
|
|
||||||
<button mat-icon-button disabled></button>
|
|
||||||
{{ node.file.name }}
|
|
||||||
</mat-tree-node>
|
|
||||||
<mat-tree-node *matTreeNodeDef="let node; when: hasChild" matTreeNodeToggle>
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
matTreeNodePadding
|
|
||||||
[attr.aria-label]="'Toggle ' + node.file.name"
|
|
||||||
>
|
|
||||||
<mat-icon class="mat-icon-rtl-mirror">
|
|
||||||
@if (treeControl.isExpanded(node)) { expand_more } @else {
|
|
||||||
chevron_right }
|
|
||||||
</mat-icon>
|
|
||||||
</button>
|
|
||||||
{{ node.file.name }}
|
|
||||||
</mat-tree-node>
|
|
||||||
</mat-tree>`,
|
|
||||||
styleUrl: './file-tree.component.scss',
|
styleUrl: './file-tree.component.scss',
|
||||||
})
|
})
|
||||||
export class FileTreeComponent {
|
export class FileTreeComponent {
|
||||||
|
|||||||
19
src/app/model/proto-message.model.ts
Normal file
19
src/app/model/proto-message.model.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export enum MessageType {
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Numeric,
|
||||||
|
List,
|
||||||
|
Map,
|
||||||
|
Object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtoMessageField {
|
||||||
|
name: string;
|
||||||
|
type: MessageType;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtoMessage {
|
||||||
|
name: string;
|
||||||
|
values: ProtoMessageField[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--mat-sidenav-container-shape);
|
||||||
|
padding: var(--mat-sidenav-container-shape);
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background-color 100ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host.droppable * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { MediaMatcher } from '@angular/cdk/layout';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
HostBinding,
|
||||||
|
HostListener,
|
||||||
|
OnDestroy,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { ProtoMessage } from '../model/proto-message.model';
|
||||||
|
import { ProtoDefinitionService } from '../proto-definition.service';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-proto-definition-selector',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatListModule, MatButtonModule],
|
||||||
|
template: `
|
||||||
|
<h2>Protobuf Definitions</h2>
|
||||||
|
<mat-list>
|
||||||
|
@for (item of definitionFiles(); track $index) {
|
||||||
|
<mat-list-item (click)="selectProtoDefinition(item)">{{
|
||||||
|
item.name
|
||||||
|
}}</mat-list-item>
|
||||||
|
}
|
||||||
|
</mat-list>
|
||||||
|
<button mat-button (click)="protoSelector.click()">
|
||||||
|
Select definitions
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
#protoSelector
|
||||||
|
type="file"
|
||||||
|
(change)="addDefinitionFiles()"
|
||||||
|
accept=".proto"
|
||||||
|
/>
|
||||||
|
<mat-list>
|
||||||
|
@for (item of selectedDefinition(); track $index) {
|
||||||
|
<mat-list-item (click)="messageSelected.emit(item)">{{
|
||||||
|
item.name
|
||||||
|
}}</mat-list-item>
|
||||||
|
}
|
||||||
|
</mat-list>
|
||||||
|
<!-- TODO: more detail when dragging over so user knows they can drop the file -->
|
||||||
|
`,
|
||||||
|
styleUrl: './proto-definition-selector.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ProtoDefinitionSelectorComponent {
|
||||||
|
protoSelector = viewChild<ElementRef<HTMLInputElement>>('protoSelector');
|
||||||
|
messageSelected = output<ProtoMessage>();
|
||||||
|
|
||||||
|
protected definitionFiles = signal<File[]>([]);
|
||||||
|
protected selectedDefinition = signal<ProtoMessage[]>([]);
|
||||||
|
protected isDragging = signal(false);
|
||||||
|
|
||||||
|
private currentFiles: string[] = [];
|
||||||
|
|
||||||
|
@HostBinding('class.droppable')
|
||||||
|
get droppable() {
|
||||||
|
return this.isDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private protoDefinitionService: ProtoDefinitionService,
|
||||||
|
private elementRef: ElementRef
|
||||||
|
) {}
|
||||||
|
|
||||||
|
protected async addDefinitionFiles() {
|
||||||
|
const files = this.protoSelector()?.nativeElement.files;
|
||||||
|
if (files) {
|
||||||
|
const definitionFiles = this.definitionFiles();
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
if (!this.currentFiles.includes(files[i].name)) {
|
||||||
|
definitionFiles.push(files[i]);
|
||||||
|
this.currentFiles.push(files[i].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.definitionFiles.set(definitionFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async selectProtoDefinition(file: File) {
|
||||||
|
try {
|
||||||
|
const protoContents = await file.text();
|
||||||
|
const messageObjects =
|
||||||
|
await this.protoDefinitionService.parseProtoDefinition(protoContents);
|
||||||
|
this.selectedDefinition.set(messageObjects);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(
|
||||||
|
"Failed to parse protobuf definition, please check it's a valid file."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('dragover', ['$event'])
|
||||||
|
onDrag(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('drop', ['$event'])
|
||||||
|
onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const protoSelector = this.protoSelector();
|
||||||
|
if (protoSelector) {
|
||||||
|
protoSelector.nativeElement.files = event.dataTransfer?.files ?? null;
|
||||||
|
}
|
||||||
|
this.isDragging.set(false);
|
||||||
|
this.addDefinitionFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('dragenter')
|
||||||
|
onDragEnter() {
|
||||||
|
this.isDragging.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('dragleave', ['$event'])
|
||||||
|
onDragLeave(event: DragEvent) {
|
||||||
|
if (event.target) this.isDragging.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app/proto-definition.service.spec.ts
Normal file
57
src/app/proto-definition.service.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { MessageType, ProtoMessage } from './model/proto-message.model';
|
||||||
|
import { ProtoDefinitionService } from './proto-definition.service';
|
||||||
|
|
||||||
|
let testProto = `
|
||||||
|
syntax="proto3";
|
||||||
|
|
||||||
|
message Test {
|
||||||
|
string hello = 1;
|
||||||
|
int32 hello2 = 2;
|
||||||
|
int64 hello3 = 3;
|
||||||
|
float hello4 = 4;
|
||||||
|
double hello5 = 5;
|
||||||
|
bool hello6 = 6;
|
||||||
|
repeated string hello7 = 7;
|
||||||
|
map<string, string> hello8 = 8;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('TestService', () => {
|
||||||
|
let service: ProtoDefinitionService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideExperimentalZonelessChangeDetection()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(ProtoDefinitionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert parse protobuf correctly', async () => {
|
||||||
|
const testMessages = await service.parseProtoDefinition(testProto);
|
||||||
|
const converted = testMessages[0];
|
||||||
|
|
||||||
|
expect(converted.name).toBe('Test');
|
||||||
|
|
||||||
|
expect(converted.values.length).toBe(8);
|
||||||
|
checkNameAndType(converted, 'hello', MessageType.String);
|
||||||
|
checkNameAndType(converted, 'hello2', MessageType.Numeric);
|
||||||
|
checkNameAndType(converted, 'hello3', MessageType.Numeric);
|
||||||
|
checkNameAndType(converted, 'hello4', MessageType.Numeric);
|
||||||
|
checkNameAndType(converted, 'hello5', MessageType.Numeric);
|
||||||
|
checkNameAndType(converted, 'hello6', MessageType.Boolean);
|
||||||
|
checkNameAndType(converted, 'hello7', MessageType.List);
|
||||||
|
checkNameAndType(converted, 'hello8', MessageType.Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkNameAndType = (
|
||||||
|
converted: ProtoMessage,
|
||||||
|
name: string,
|
||||||
|
type: MessageType
|
||||||
|
) => {
|
||||||
|
const field = converted.values.find((value) => value.name === name);
|
||||||
|
expect(field?.type).toBe(type);
|
||||||
|
};
|
||||||
70
src/app/proto-definition.service.ts
Normal file
70
src/app/proto-definition.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { FieldBase, MapField, ReflectionObject, parse } from 'protobufjs';
|
||||||
|
import { MessageType, ProtoMessage } from './model/proto-message.model';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ProtoDefinitionService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async parseProtoDefinition(protoContents: string): Promise<ProtoMessage[]> {
|
||||||
|
const definition = await parse(protoContents);
|
||||||
|
const messages = definition.root.nested;
|
||||||
|
if (messages) {
|
||||||
|
const objectDefinitions = Object.values(messages);
|
||||||
|
const messageObjects = [];
|
||||||
|
for (const objectDefinition of objectDefinitions) {
|
||||||
|
// TODO: Better way to check for message type (i.e. not enum)?
|
||||||
|
if ('fieldsArray' in objectDefinition) {
|
||||||
|
messageObjects.push(
|
||||||
|
this.convertProtoDefinitionToModel(objectDefinition)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messageObjects;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertProtoDefinitionToModel(
|
||||||
|
messageDefinition?: ReflectionObject
|
||||||
|
): ProtoMessage {
|
||||||
|
if (messageDefinition && 'fieldsArray' in messageDefinition) {
|
||||||
|
const convertedFields: ProtoMessage = {
|
||||||
|
name: messageDefinition.name,
|
||||||
|
values: [],
|
||||||
|
};
|
||||||
|
const fields = messageDefinition.fieldsArray as FieldBase[];
|
||||||
|
// TODO: Needs to be a visitor/tree search since messages/enums may be nested
|
||||||
|
for (const field of fields) {
|
||||||
|
// TODO: Better way to check this?
|
||||||
|
if ('rule' in field && field.rule === 'repeated') {
|
||||||
|
// Repeated/list field
|
||||||
|
} else if (field instanceof MapField) {
|
||||||
|
// Map
|
||||||
|
} else {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'string':
|
||||||
|
break;
|
||||||
|
case 'int32':
|
||||||
|
case 'int64':
|
||||||
|
case 'float':
|
||||||
|
case 'double':
|
||||||
|
break;
|
||||||
|
case 'bool':
|
||||||
|
break;
|
||||||
|
// TODO: bytes as well, though that's pretty useless (can't really represent/edit it?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
convertedFields.values.push({
|
||||||
|
name: field.name,
|
||||||
|
type: MessageType.String,
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return convertedFields;
|
||||||
|
}
|
||||||
|
throw 'ReflectionObject is not a message';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<title>Test</title>
|
<title>Test</title>
|
||||||
<base href="/">
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
<link
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||||
</head>
|
rel="stylesheet"
|
||||||
<body class="mat-typography">
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="mat-typography">
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,7 +5,16 @@
|
|||||||
|
|
||||||
@mixin custom-colours($theme) {
|
@mixin custom-colours($theme) {
|
||||||
.mat-toolbar {
|
.mat-toolbar {
|
||||||
background-color: mat.get-theme-color($theme, primary-container);
|
background-color: mat.get-theme-color($theme, tertiary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
app-proto-definition-selector {
|
||||||
|
background-color: mat.get-theme-color($theme, secondary-container);
|
||||||
|
transition: background-color 100ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.droppable {
|
||||||
|
background-color: mat.get-theme-color($theme, secondary, 40);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
test.proto
Normal file
12
test.proto
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
syntax="proto3";
|
||||||
|
|
||||||
|
message Test {
|
||||||
|
string hello = 1;
|
||||||
|
int32 hello2 = 2;
|
||||||
|
int64 hello3 = 3;
|
||||||
|
float hello4 = 4;
|
||||||
|
double hello5 = 5;
|
||||||
|
bool hello6 = 6;
|
||||||
|
repeated string hello7 = 7;
|
||||||
|
map<string, string> hello8 = 8;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user