Tidy up editor, file tree, get list field working, allow creating new items
This commit is contained in:
@@ -18,18 +18,13 @@
|
||||
</span>
|
||||
</mat-toolbar>
|
||||
<mat-sidenav-container autosize>
|
||||
@if(selectedDirectory()) {
|
||||
<mat-sidenav
|
||||
[mode]="isMobile() ? 'over' : 'side'"
|
||||
[opened]="leftSideOpen()"
|
||||
(closed)="leftSideOpen.set(false)"
|
||||
>
|
||||
<app-file-tree
|
||||
[workspaceName]="directoryName()"
|
||||
[files]="files()"
|
||||
></app-file-tree>
|
||||
<app-file-tree (fileSelected)="fileSelected($event)"></app-file-tree>
|
||||
</mat-sidenav>
|
||||
}
|
||||
<mat-sidenav
|
||||
[opened]="rightSideOpen()"
|
||||
[mode]="isMobile() ? 'over' : 'side'"
|
||||
@@ -41,21 +36,8 @@
|
||||
></app-proto-definition-selector>
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content>
|
||||
@if (!selectedDirectory()) {
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
"
|
||||
>
|
||||
<button mat-button (click)="selectDirectory()" style="margin: auto">
|
||||
Open Folder
|
||||
</button>
|
||||
</div>
|
||||
}@else { @if(selectedMessage()) {
|
||||
@if(selectedMessage()) {
|
||||
<app-editor [selectedMessage]="selectedMessage()!"></app-editor>
|
||||
} }
|
||||
}
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
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';
|
||||
@@ -8,17 +8,14 @@ import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
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 { OpenFolderMessage } from './messages/openfolder.message';
|
||||
import { ProtoMessage } from './model/proto-message.model';
|
||||
import { ProtoDefinitionSelectorComponent } from './proto-definition-selector/proto-definition-selector.component';
|
||||
import { readTextFile } from '@tauri-apps/api/fs';
|
||||
const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait];
|
||||
|
||||
@Component({
|
||||
@@ -40,27 +37,15 @@ const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait];
|
||||
],
|
||||
})
|
||||
export class AppComponent {
|
||||
protected selectedDirectory = signal<string | null>(null);
|
||||
protected files = signal<FileOrFolder[]>([]);
|
||||
protected selectedFileContents = signal<string | undefined>(undefined);
|
||||
protected selectedMessage = signal<ProtoMessage | undefined>(undefined);
|
||||
protected rightSideOpen = signal(true);
|
||||
protected leftSideOpen = signal(true);
|
||||
|
||||
private unlisten?: UnlistenFn;
|
||||
|
||||
protected isMobile = signal(
|
||||
this.breakpointObserver.isMatched(mobileBreakpoints)
|
||||
);
|
||||
|
||||
protected directoryName = computed(() => {
|
||||
const directory = this.selectedDirectory();
|
||||
if (directory) {
|
||||
const directorySplit = directory.split('/');
|
||||
return directorySplit[directorySplit.length - 1];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
constructor(private breakpointObserver: BreakpointObserver) {
|
||||
breakpointObserver
|
||||
.observe(mobileBreakpoints)
|
||||
@@ -68,60 +53,18 @@ export class AppComponent {
|
||||
.subscribe((matches) => this.isMobile.set(matches.matches));
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.unlisten = await listen(
|
||||
'openfolder',
|
||||
async (event: OpenFolderMessage) => {
|
||||
await this.selectDirectory();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnDestroy() {
|
||||
if (this.unlisten) {
|
||||
this.unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
async selectDirectory() {
|
||||
const selectedDirectory = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (Array.isArray(selectedDirectory)) {
|
||||
message('Only a single folder can be selected at a time');
|
||||
return;
|
||||
} else {
|
||||
this.selectedDirectory.set(selectedDirectory);
|
||||
}
|
||||
if (selectedDirectory) {
|
||||
const entries = await readDir(selectedDirectory, {
|
||||
recursive: true,
|
||||
});
|
||||
const splitNumbers = /(\d)+|(\D)+/;
|
||||
this.files.set(
|
||||
entries
|
||||
.sort(this.sortFiles(splitNumbers))
|
||||
.map((entry) => this.mapEntry(entry, splitNumbers))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapEntry(entry: FileEntry, splitNumbers: RegExp): FileOrFolder {
|
||||
return {
|
||||
isDirectory: entry.children != null,
|
||||
name: entry.name || '',
|
||||
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);
|
||||
}
|
||||
|
||||
protected async fileSelected(file: FileOrFolder) {
|
||||
try {
|
||||
this.selectedFileContents.set(await readTextFile(file.path));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(
|
||||
'Failed to read selected file, please ensure you have appropriate permissions and try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<h2>{{ selectedMessage().name }}</h2>
|
||||
<div>
|
||||
@for (item of selectedMessage().values; track $index) {
|
||||
<app-proto-field
|
||||
[label]="item.name"
|
||||
[configuration]="item.configuration"
|
||||
[(value)]="values()[$index]"
|
||||
></app-proto-field>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
padding: var(--mat-sidenav-container-shape);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
signal,
|
||||
@@ -22,8 +23,12 @@ export class EditorComponent {
|
||||
// TODO: This needs to be reworked so we have a local property and implement some kind of auto-save
|
||||
fileContents = input<string>();
|
||||
selectedMessage = input.required<ProtoMessage>();
|
||||
// TODO: This needs to start with the parsed file contents, and get updated when the code value changes
|
||||
protected values = signal([]);
|
||||
|
||||
protected values = computed(() => {
|
||||
const message = this.selectedMessage();
|
||||
return message.values.map((value) => null);
|
||||
});
|
||||
|
||||
private code = viewChild<ElementRef<HTMLElement>>('code');
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<h2>{{ workspaceName() }}</h2>
|
||||
<h2>@if(workspaceName()) {workspaceName()} @else { No Worspace Selected}</h2>
|
||||
@if(!selectedDirectory()) {
|
||||
<div>
|
||||
<button mat-button (click)="selectDirectory()" style="margin: auto">
|
||||
Open Folder
|
||||
</button>
|
||||
</div>
|
||||
}@else {
|
||||
<mat-tree [dataSource]="dataSource()" [treeControl]="treeControl">
|
||||
<mat-tree-node
|
||||
*matTreeNodeDef="let node"
|
||||
@@ -24,3 +31,4 @@
|
||||
{{ node.file.name }}
|
||||
</mat-tree-node>
|
||||
</mat-tree>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { FlatTreeControl } from '@angular/cdk/tree';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import {
|
||||
@@ -8,6 +15,10 @@ import {
|
||||
MatTreeFlattener,
|
||||
MatTreeModule,
|
||||
} from '@angular/material/tree';
|
||||
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 { OpenFolderMessage } from '../messages/openfolder.message';
|
||||
|
||||
export interface FileOrFolder {
|
||||
isDirectory: boolean;
|
||||
@@ -29,12 +40,10 @@ interface FileNode {
|
||||
templateUrl: './file-tree.component.html',
|
||||
styleUrl: './file-tree.component.scss',
|
||||
})
|
||||
export class FileTreeComponent {
|
||||
workspaceName = input<string | null>();
|
||||
files = input<FileOrFolder[]>([]);
|
||||
|
||||
export class FileTreeComponent implements OnInit, OnDestroy {
|
||||
fileSelected = output<FileOrFolder>();
|
||||
|
||||
// File tree
|
||||
protected hasChild = (_: number, node: FileNode) => node.expandable;
|
||||
|
||||
private _transformer = (node: FileOrFolder, level: number) => ({
|
||||
@@ -64,4 +73,71 @@ export class FileTreeComponent {
|
||||
dataSource.data = this.files();
|
||||
return dataSource;
|
||||
});
|
||||
|
||||
// Folder selection
|
||||
protected selectedDirectory = signal<string | null>(null);
|
||||
protected files = signal<FileOrFolder[]>([]);
|
||||
protected workspaceName = computed(() => {
|
||||
const directory = this.selectedDirectory();
|
||||
if (directory) {
|
||||
const directorySplit = directory.split('/');
|
||||
return directorySplit[directorySplit.length - 1];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
private unlisten?: UnlistenFn;
|
||||
|
||||
async ngOnInit() {
|
||||
this.unlisten = await listen(
|
||||
'openfolder',
|
||||
async (event: OpenFolderMessage) => {
|
||||
await this.selectDirectory();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnDestroy() {
|
||||
if (this.unlisten) {
|
||||
this.unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
async selectDirectory() {
|
||||
const selectedDirectory = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (Array.isArray(selectedDirectory)) {
|
||||
message('Only a single folder can be selected at a time');
|
||||
return;
|
||||
} else {
|
||||
this.selectedDirectory.set(selectedDirectory);
|
||||
}
|
||||
if (selectedDirectory) {
|
||||
const entries = await readDir(selectedDirectory, {
|
||||
recursive: true,
|
||||
});
|
||||
const splitNumbers = /(\d)+|(\D)+/;
|
||||
this.files.set(
|
||||
entries
|
||||
.sort(this.sortFiles(splitNumbers))
|
||||
.map((entry) => this.mapEntry(entry, splitNumbers))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapEntry(entry: FileEntry, splitNumbers: RegExp): FileOrFolder {
|
||||
return {
|
||||
isDirectory: entry.children != null,
|
||||
name: entry.name || '',
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
forwardRef,
|
||||
input,
|
||||
model,
|
||||
} from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ListMessage } from '../model/proto-message.model';
|
||||
import { ProtoFieldComponent } from '../proto-field/proto-field.component';
|
||||
import {
|
||||
EnumMessage,
|
||||
ListMessage,
|
||||
ProtoMessageField,
|
||||
} from '../model/proto-message.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list-field',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatButton, MatIconModule, ProtoFieldComponent],
|
||||
template: ` @for(value of values(); track $index) {
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
forwardRef(() => ProtoFieldComponent),
|
||||
],
|
||||
template: ` <h3>{{ label() }}</h3>
|
||||
@if(values()) { @for(value of values(); track $index) {
|
||||
<div class="row-wrapper">
|
||||
<app-proto-field
|
||||
[configuration]="configuration().subConfiguration"
|
||||
@@ -28,19 +33,24 @@ import {
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} }
|
||||
<button mat-icon-button (click)="add()"><mat-icon>add</mat-icon></button>`,
|
||||
styleUrl: './list-field.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ListFieldComponent {
|
||||
label = input<string>();
|
||||
configuration = input.required<ListMessage>();
|
||||
values = model<any[]>();
|
||||
|
||||
add() {
|
||||
const newValues = this.values();
|
||||
newValues?.push(null);
|
||||
this.values.set(newValues);
|
||||
if (newValues) {
|
||||
newValues.push(null);
|
||||
this.values.set(newValues);
|
||||
} else {
|
||||
this.values.set([null]);
|
||||
}
|
||||
}
|
||||
|
||||
remove(index: number) {
|
||||
|
||||
@@ -20,6 +20,9 @@ import { ProtoDefinitionService } from '../proto-definition.service';
|
||||
imports: [CommonModule, MatButtonModule, MatListModule],
|
||||
template: `
|
||||
<h2>Protobuf Definitions</h2>
|
||||
<button mat-button (click)="protoSelector.click()">
|
||||
Select definitions
|
||||
</button>
|
||||
<mat-action-list>
|
||||
@for (item of definitionFiles(); track $index) {
|
||||
<button mat-list-item (click)="selectProtoDefinition(item)">
|
||||
@@ -27,9 +30,6 @@ import { ProtoDefinitionService } from '../proto-definition.service';
|
||||
</button>
|
||||
}
|
||||
</mat-action-list>
|
||||
<button mat-button (click)="protoSelector.click()">
|
||||
Select definitions
|
||||
</button>
|
||||
@if(selectedProtoFile()) {
|
||||
<h3>Messages in {{ selectedProtoFile()?.name }}</h3>
|
||||
<mat-action-list>
|
||||
@@ -67,10 +67,7 @@ export class ProtoDefinitionSelectorComponent {
|
||||
return this.isDragging();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private protoDefinitionService: ProtoDefinitionService,
|
||||
private elementRef: ElementRef
|
||||
) {}
|
||||
constructor(private protoDefinitionService: ProtoDefinitionService) {}
|
||||
|
||||
protected async addDefinitionFiles() {
|
||||
const files = this.protoSelector()?.nativeElement.files;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MessageConfiguration,
|
||||
MessageTypeEnum,
|
||||
} from '../model/proto-message.model';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
@Component({
|
||||
selector: 'app-proto-field',
|
||||
@@ -28,22 +29,36 @@ import {
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatInputModule,
|
||||
],
|
||||
template: `@switch (configuration().type) { @case (MessageTypeEnum.String) {
|
||||
<input [(ngModel)]="value" />
|
||||
<mat-form-field>
|
||||
<mat-label>{{ label() }}</mat-label>
|
||||
<input matInput [(ngModel)]="value" />
|
||||
</mat-form-field>
|
||||
} @case (MessageTypeEnum.Numeric) {
|
||||
<input type="number" [(ngModel)]="value" />
|
||||
<mat-form-field>
|
||||
<mat-label>{{ label() }}</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="value" />
|
||||
</mat-form-field>
|
||||
} @case (MessageTypeEnum.Boolean) {
|
||||
<mat-checkbox [(ngModel)]="value"></mat-checkbox>
|
||||
<p>
|
||||
<mat-checkbox [(ngModel)]="value">{{ label() }}</mat-checkbox>
|
||||
</p>
|
||||
} @case(MessageTypeEnum.Enum) {
|
||||
<mat-select [(value)]="value">
|
||||
@for(option of enumConfiguration()!.options; track
|
||||
enumConfiguration()!.options) {
|
||||
<mat-option [value]="option"></mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-form-field>
|
||||
<mat-label>{{ label() }}</mat-label>
|
||||
<mat-select [(value)]="value">
|
||||
@for(option of enumConfiguration()!.options; track
|
||||
enumConfiguration()!.options) {
|
||||
<mat-option>None</mat-option>
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
} @case (MessageTypeEnum.List) {
|
||||
<app-list-field
|
||||
[label]="label()"
|
||||
[configuration]="listConfiguration()!"
|
||||
[(values)]="value"
|
||||
></app-list-field>
|
||||
@@ -53,6 +68,7 @@ import {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProtoFieldComponent {
|
||||
label = input<string>();
|
||||
configuration = input.required<MessageConfiguration>();
|
||||
value = model<any>();
|
||||
|
||||
|
||||
@@ -41,4 +41,6 @@ body {
|
||||
--mat-tree-node-min-height: 24px;
|
||||
--mat-tree-node-text-size: 14px;
|
||||
--mdc-icon-button-state-layer-size: 24px;
|
||||
--mat-icon-button-touch-target-display: none;
|
||||
--mdc-list-list-item-one-line-container-height: 24px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user