diff --git a/angular.json b/angular.json
index dec4bcf..845d330 100644
--- a/angular.json
+++ b/angular.json
@@ -18,18 +18,21 @@
"outputPath": "dist/proto-creator",
"index": "src/index.html",
"browser": "src/main.ts",
- "polyfills": [
- ],
+ "polyfills": [],
"tsConfig": "tsconfig.app.json",
"assets": ["src/assets"],
- "styles": ["src/styles.scss"]
+ "styles": [
+ "src/styles.scss",
+ "highlight.js/styles/atom-one-dark.min.css"
+ ],
+ "allowedCommonJsDependencies": ["protobufjs"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
- "maximumWarning": "500kb",
+ "maximumWarning": "700kb",
"maximumError": "1mb"
},
{
@@ -69,8 +72,7 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
- "polyfills": [
- ],
+ "polyfills": [],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
@@ -79,9 +81,7 @@
"input": "public"
}
],
- "styles": [
- "src/styles.scss"
- ],
+ "styles": ["src/styles.scss"],
"scripts": []
}
}
diff --git a/bun.lockb b/bun.lockb
index 8d07c1e..3c185b6 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 607a8cb..1420d33 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"scripts": {
"ng": "ng",
"start": "ng serve",
+ "test": "ng test",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"tauri": "tauri"
@@ -21,6 +22,7 @@
"@angular/platform-browser-dynamic": "^18.0.0",
"@angular/router": "^18.0.0",
"@tauri-apps/api": "^1.5.6",
+ "highlight.js": "^11.9.0",
"protobufjs": "~7.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 5726066..f78b8d6 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -46,7 +46,7 @@ fn build_menu(app_name: &str) -> Menu {
}
let open_folder =
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_native_item(MenuItem::CloseWindow);
#[cfg(not(target_os = "macos"))]
diff --git a/src/app/app.component.css b/src/app/app.component.css
deleted file mode 100644
index e69de29..0000000
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 7fa7c18..1144aaa 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,12 +1,42 @@
-BufPiv
+
+ BufPiv
+
+
+
+
+
@if(selectedDirectory()) {
-
+
}
+
+
+
@if (!selectedDirectory()) {
- }@else { }
+ }@else {
+
+ }
-
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index 5011c3d..d80ee9d 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -8,8 +8,18 @@ mat-toolbar {
flex: 0 0 auto;
padding-top: 24px;
--mat-toolbar-standard-height: 70px;
+ display: flex;
+ justify-content: space-between;
}
mat-sidenav-container {
width: 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;
+}
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 4045cb9..648421f 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,19 +1,25 @@
+import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
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 { 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 {
FileOrFolder,
FileTreeComponent,
} 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 { ProtoMessage } from './model/proto-message.model';
+import { ProtoDefinitionSelectorComponent } from './proto-definition-selector/proto-definition-selector.component';
+const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait];
@Component({
selector: 'app-root',
@@ -29,14 +35,30 @@ import { OpenFolderMessage } from './messages/openfolder.message';
MatTreeModule,
FileTreeComponent,
MatIconModule,
+ EditorComponent,
+ ProtoDefinitionSelectorComponent,
],
})
export class AppComponent {
protected selectedDirectory = signal(null);
protected files = signal([]);
+ protected selectedMessage = signal(undefined);
+ protected rightSideOpen = signal(true);
+ protected leftSideOpen = signal(true);
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() {
this.unlisten = await listen(
'openfolder',
@@ -83,9 +105,14 @@ export class AppComponent {
children: entry.children
?.sort(this.sortFiles(splitNumbers))
.map((entry) => this.mapEntry(entry, splitNumbers)),
+ path: entry.path,
};
}
private sortFiles = (splitNumbers: RegExp) => (a: FileEntry, b: FileEntry) =>
a.name?.localeCompare(b.name ?? '') ?? 0;
+
+ protected selectMessage(message: ProtoMessage) {
+ this.selectedMessage.set(message);
+ }
}
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index d5506d4..7262144 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -1,4 +1,5 @@
import {
+ APP_INITIALIZER,
ApplicationConfig,
provideExperimentalZonelessChangeDetection,
} from '@angular/core';
@@ -6,11 +7,39 @@ import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
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 = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes),
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' },
+ },
],
};
diff --git a/src/app/editor/editor.component.css b/src/app/editor/editor.component.css
deleted file mode 100644
index 5d4e87f..0000000
--- a/src/app/editor/editor.component.css
+++ /dev/null
@@ -1,3 +0,0 @@
-:host {
- display: block;
-}
diff --git a/src/app/editor/editor.component.html b/src/app/editor/editor.component.html
index 2858489..0cbf154 100644
--- a/src/app/editor/editor.component.html
+++ b/src/app/editor/editor.component.html
@@ -1 +1,3 @@
-
+
+
+{{fileContents()}}
diff --git a/src/app/editor/editor.component.scss b/src/app/editor/editor.component.scss
index 5d4e87f..e69de29 100644
--- a/src/app/editor/editor.component.scss
+++ b/src/app/editor/editor.component.scss
@@ -1,3 +0,0 @@
-:host {
- display: block;
-}
diff --git a/src/app/editor/editor.component.ts b/src/app/editor/editor.component.ts
index 6cf201d..822469b 100644
--- a/src/app/editor/editor.component.ts
+++ b/src/app/editor/editor.component.ts
@@ -1,8 +1,9 @@
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 { readTextFile } from '@tauri-apps/api/fs';
-import { parse } from 'protobufjs';
+import hljs from 'highlight.js/lib/core';
+import { ProtoMessage } from '../model/proto-message.model';
+import { ProtoDefinitionService } from '../proto-definition.service';
@Component({
selector: 'app-editor',
@@ -13,18 +14,15 @@ import { parse } from 'protobufjs';
})
export class EditorComponent {
fileContents = input();
- selectedProtoPath = input();
+ selectedMessage = input();
+ private code = viewChild>('code');
- async loadProtoDefinition() {
- try {
- const protoPath = this.selectedProtoPath();
- if (protoPath) {
- const protoContents = await readTextFile(protoPath);
- const definition = await parse(protoContents);
- console.log(definition);
+ constructor(private protoDefinitionService: ProtoDefinitionService) {
+ effect(() => {
+ const element = this.code()?.nativeElement;
+ if (element) {
+ hljs.highlightElement(element);
}
- } catch (err) {
- console.error(err);
- }
+ });
}
}
diff --git a/src/app/file-tree/file-tree.component.css b/src/app/file-tree/file-tree.component.css
deleted file mode 100644
index 5d4e87f..0000000
--- a/src/app/file-tree/file-tree.component.css
+++ /dev/null
@@ -1,3 +0,0 @@
-:host {
- display: block;
-}
diff --git a/src/app/file-tree/file-tree.component.html b/src/app/file-tree/file-tree.component.html
new file mode 100644
index 0000000..16ebc35
--- /dev/null
+++ b/src/app/file-tree/file-tree.component.html
@@ -0,0 +1,23 @@
+
+
+
+ {{ node.file.name }}
+
+
+
+ {{ node.file.name }}
+
+
diff --git a/src/app/file-tree/file-tree.component.ts b/src/app/file-tree/file-tree.component.ts
index dc4a908..984e7cc 100644
--- a/src/app/file-tree/file-tree.component.ts
+++ b/src/app/file-tree/file-tree.component.ts
@@ -19,6 +19,7 @@ export interface FileOrFolder {
isDirectory: boolean;
name: string;
children?: FileOrFolder[];
+ path: string;
}
interface FileNode {
@@ -31,29 +32,7 @@ interface FileNode {
selector: 'app-file-tree',
standalone: true,
imports: [CommonModule, MatTreeModule, MatIconModule, MatButtonModule],
- template: `
-
-
- {{ node.file.name }}
-
-
-
- {{ node.file.name }}
-
- `,
+ templateUrl: './file-tree.component.html',
styleUrl: './file-tree.component.scss',
})
export class FileTreeComponent {
diff --git a/src/app/model/proto-message.model.ts b/src/app/model/proto-message.model.ts
new file mode 100644
index 0000000..bd516c8
--- /dev/null
+++ b/src/app/model/proto-message.model.ts
@@ -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[];
+}
diff --git a/src/app/proto-definition-selector/proto-definition-selector.component.css b/src/app/proto-definition-selector/proto-definition-selector.component.css
new file mode 100644
index 0000000..6ead0bc
--- /dev/null
+++ b/src/app/proto-definition-selector/proto-definition-selector.component.css
@@ -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;
+}
diff --git a/src/app/proto-definition-selector/proto-definition-selector.component.ts b/src/app/proto-definition-selector/proto-definition-selector.component.ts
new file mode 100644
index 0000000..38c18c0
--- /dev/null
+++ b/src/app/proto-definition-selector/proto-definition-selector.component.ts
@@ -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: `
+ Protobuf Definitions
+
+ @for (item of definitionFiles(); track $index) {
+ {{
+ item.name
+ }}
+ }
+
+
+
+
+ @for (item of selectedDefinition(); track $index) {
+ {{
+ item.name
+ }}
+ }
+
+
+ `,
+ styleUrl: './proto-definition-selector.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProtoDefinitionSelectorComponent {
+ protoSelector = viewChild>('protoSelector');
+ messageSelected = output();
+
+ protected definitionFiles = signal([]);
+ protected selectedDefinition = signal([]);
+ 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);
+ }
+}
diff --git a/src/app/proto-definition.service.spec.ts b/src/app/proto-definition.service.spec.ts
new file mode 100644
index 0000000..70b54b3
--- /dev/null
+++ b/src/app/proto-definition.service.spec.ts
@@ -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 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);
+};
diff --git a/src/app/proto-definition.service.ts b/src/app/proto-definition.service.ts
new file mode 100644
index 0000000..0b38f4c
--- /dev/null
+++ b/src/app/proto-definition.service.ts
@@ -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 {
+ 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';
+ }
+}
diff --git a/src/index.html b/src/index.html
index 645c4d4..3590418 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1,15 +1,21 @@
-
-
- Test
-
-
-
-
-
-
-
-
-
+
+
+ Test
+
+
+
+
+
+
+
+
+
diff --git a/src/styles.scss b/src/styles.scss
index 591a754..c841266 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -5,7 +5,16 @@
@mixin custom-colours($theme) {
.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);
}
}
diff --git a/test.proto b/test.proto
new file mode 100644
index 0000000..268b235
--- /dev/null
+++ b/test.proto
@@ -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 hello8 = 8;
+}
\ No newline at end of file