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