Tidy up editor, file tree, get list field working, allow creating new items

This commit is contained in:
2024-07-05 17:45:43 +09:30
parent 0f60377e91
commit a22b6943aa
11 changed files with 171 additions and 127 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
:host {
padding: var(--mat-sidenav-container-shape);
}

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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