Start adding editor, fix up sidebar styling

This commit is contained in:
2024-06-22 14:19:23 +09:30
parent c9c7878263
commit 9c2531a034
24 changed files with 489 additions and 81 deletions

View File

@@ -1,12 +1,42 @@
<mat-toolbar data-tauri-drag-region color="secondary"
><h1>BufPiv</h1></mat-toolbar
>
<mat-toolbar data-tauri-drag-region color="secondary">
<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>
@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>
</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>
@if (!selectedDirectory()) {
<div
@@ -21,7 +51,8 @@
Open Folder
</button>
</div>
}@else { }
}@else {
<app-editor [selectedMessage]="selectedMessage()"></app-editor>
}
</mat-sidenav-content>
<!-- TODO: Proto selector (add/remove proto definitinos, select definition for current file) on right side -->
</mat-sidenav-container>

View File

@@ -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;
}

View File

@@ -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<string | null>(null);
protected files = signal<FileOrFolder[]>([]);
protected selectedMessage = signal<ProtoMessage | undefined>(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);
}
}

View File

@@ -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' },
},
],
};

View File

@@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@@ -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<string>();
selectedProtoPath = input<string>();
selectedMessage = input<ProtoMessage>();
private code = viewChild<ElementRef<HTMLElement>>('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);
}
});
}
}

View File

@@ -1,3 +0,0 @@
:host {
display: block;
}

View 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>

View File

@@ -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: `<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>`,
templateUrl: './file-tree.component.html',
styleUrl: './file-tree.component.scss',
})
export class FileTreeComponent {

View 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[];
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View 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);
};

View 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';
}
}

View File

@@ -1,15 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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 href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
<head>
<meta charset="utf-8" />
<title>Test</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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
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>
</body>
</html>

View File

@@ -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);
}
}