Implement dev mode nice UI (#17471)

Implement a nice terminal UI for dev mode using INK

<img width="1512" height="721" alt="image"
src="https://github.com/user-attachments/assets/79a71f37-1b31-4761-9e8d-718ef029ceb8"
/>
This commit is contained in:
martmull
2026-01-27 16:34:28 +01:00
committed by GitHub
parent bc7791871f
commit f4ca69a474
31 changed files with 911 additions and 258 deletions
@@ -13,7 +13,6 @@ describe('rich-app app:dev', () => {
beforeAll(async () => {
result = await runAppDev({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
@@ -1,36 +1,35 @@
import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { sanitizeAnsi } from '@/cli/__tests__/integration/utils/sanitize-ansi.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = getOutputByPrefix(getResult().output, 'init');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain(
'[init] 🚀 Starting Twenty Application Development Mode',
);
expect(output).toContain('[init] 📁 App Path:');
expect(output).toContain('Application');
expect(output).toContain('Name: Loading...');
expect(output).toContain('Status: o Idle');
});
it('should contain dev-mode build messages', () => {
const output = getOutputByPrefix(getResult().output, 'dev-mode');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('[dev-mode] Building manifest...');
expect(output).toContain('[dev-mode] Successfully built manifest');
expect(output).toContain('Building manifest');
expect(output).toContain('Successfully built manifest');
});
it('should contain dev-mode function build messages', () => {
const output = getOutputByPrefix(getResult().output, 'dev-mode');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('[dev-mode] ✓ Successfully built');
expect(output).toContain('Successfully built');
});
it('should contain dev-mode sync messages', () => {
const output = getOutputByPrefix(getResult().output, 'dev-mode');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('[dev-mode] ✓ Synced');
expect(output).toContain('✓ Synced');
});
});
};
@@ -14,7 +14,6 @@ describe('root-app app:dev', () => {
beforeAll(async () => {
result = await runAppDev({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
@@ -1,36 +1,35 @@
import { getOutputByPrefix } from '@/cli/__tests__/integration/utils/get-output-by-prefix.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { sanitizeAnsi } from '@/cli/__tests__/integration/utils/sanitize-ansi.util';
export const defineConsoleOutputTests = (
getResult: () => RunCliCommandResult,
): void => {
describe('console output', () => {
it('should contain init messages', () => {
const output = getOutputByPrefix(getResult().output, 'init');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain(
'[init] 🚀 Starting Twenty Application Development Mode',
);
expect(output).toContain('[init] 📁 App Path:');
expect(output).toContain('Application');
expect(output).toContain('Name: Loading...');
expect(output).toContain('Status: o Idle');
});
it('should contain dev-mode build messages', () => {
const output = getOutputByPrefix(getResult().output, 'dev-mode');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('[dev-mode] Building manifest...');
expect(output).toContain('[dev-mode] Successfully built manifest');
expect(output).toContain('Building manifest');
expect(output).toContain('Successfully built manifest');
});
it('should contain dev-mode function build messages', () => {
const output = getOutputByPrefix(getResult().output, 'dev-mode');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('[dev-mode] ✓ Successfully built');
expect(output).toContain('Successfully built');
});
it('should contain dev-mode sync messages', () => {
const output = getOutputByPrefix(getResult().output, 'dev-mode');
const output = sanitizeAnsi(getResult().output);
expect(output).toContain('[dev-mode] ✓ Synced');
expect(output).toContain('✓ Synced');
});
});
};
@@ -16,7 +16,7 @@ export const runAppDev = (
return runCliCommand({
command: 'app:dev',
args: [appPath],
waitForOutput: ['[dev-mode] ✓ Synced'],
waitForOutput: ['✓ Synced'],
timeout,
});
};
@@ -36,7 +36,6 @@ export const runCliCommand = (
env: {
...process.env,
FORCE_COLOR: '0',
TWENTY_SKIP_SERVER_CHECK: 'true',
},
},
);
@@ -0,0 +1,2 @@
export const sanitizeAnsi = (output: string): string =>
output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
@@ -1,4 +1,3 @@
import { createLogger } from '@/cli/utilities/build/common/logger';
import {
createFrontComponentsWatcher,
createFunctionsWatcher,
@@ -8,12 +7,11 @@ import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifes
import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { DevModeOrchestrator } from '@/cli/utilities/dev/dev-mode-orchestrator';
import { ApiService } from '@/cli/utilities/api/api-service';
import path from 'path';
import { OUTPUT_DIR } from '@/cli/utilities/build/common/constants';
import * as fs from 'fs-extra';
const initLogger = createLogger('init');
import { DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
import { renderDevUI } from '@/cli/utilities/dev/dev-ui';
export type AppDevOptions = {
appPath?: string;
@@ -26,42 +24,33 @@ export class AppDevCommand {
private functionsWatcher: EsbuildWatcher | null = null;
private frontComponentsWatcher: EsbuildWatcher | null = null;
private watchersStarted = false;
private apiService = new ApiService();
private uiStateManager: DevUiStateManager | null = null;
private unmountUI: (() => void) | null = null;
async execute(options: AppDevOptions): Promise<void> {
this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await this.checkServer();
initLogger.log('🚀 Starting Twenty Application Development Mode');
initLogger.log(`📁 App Path: ${this.appPath}`);
console.log('');
await this.cleanOutputDir();
this.uiStateManager = new DevUiStateManager({
appPath: this.appPath,
frontendUrl: process.env.FRONTEND_URL,
});
const { unmount } = await renderDevUI(this.uiStateManager);
this.unmountUI = unmount;
this.orchestrator = new DevModeOrchestrator({
appPath: this.appPath,
handleManifestBuilt: this.handleWatcherRestarts.bind(this),
uiStateManager: this.uiStateManager,
});
await this.startManifestWatcher();
this.setupGracefulShutdown();
}
private async checkServer(): Promise<void> {
if (process.env.TWENTY_SKIP_SERVER_CHECK === 'true') {
return;
}
const isAuthenticated = await this.apiService.validateAuth();
if (!isAuthenticated) {
initLogger.error(
'Please check your server is up and your credentials are correct.',
);
process.exit(1);
}
}
private async cleanOutputDir() {
const outputDir = path.join(this.appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
@@ -141,8 +130,7 @@ export class AppDevCommand {
private setupGracefulShutdown(): void {
const shutdown = async () => {
console.log('');
initLogger.warn('🛑 Stopping...');
this.unmountUI?.();
await Promise.all([
this.manifestWatcher?.close(),
@@ -18,7 +18,8 @@ export class ApiService {
private client: AxiosInstance;
private configService: ConfigService;
constructor() {
constructor(options?: { disableInterceptors: boolean }) {
const { disableInterceptors = false } = options || {};
this.configService = new ConfigService();
this.client = axios.create();
@@ -34,6 +35,10 @@ export class ApiService {
return config;
});
if (disableInterceptors) {
return;
}
this.client.interceptors.response.use(
(response) => response,
(error) => {
@@ -137,7 +137,14 @@ export class EsbuildWatcher implements RestartableWatcher {
build.onEnd(async (result) => {
try {
if (result.errors.length > 0) {
await this.onBuildError?.(result.errors.map((err) => err.text));
if (!result.errors[0].text.includes('Could not resolve')) {
await this.onBuildError?.(
result.errors.map((err) => ({
error: err.text,
location: err.location,
})),
);
}
return;
}
@@ -1,45 +0,0 @@
import chalk, { type ChalkInstance } from 'chalk';
export type LoggerContext = 'init' | 'manifest-builder' | 'dev-mode';
type LoggerConfig = {
prefix: string;
color: ChalkInstance;
};
const LOGGER_CONFIGS: Record<LoggerContext, LoggerConfig> = {
init: {
prefix: '[init]',
color: chalk.cyan,
},
'manifest-builder': {
prefix: '[manifest-builder]',
color: chalk.blue,
},
'dev-mode': {
prefix: '[dev-mode]',
color: chalk.blueBright,
},
};
export type Logger = {
log: (message: string) => void;
success: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
export const createLogger = (context: LoggerContext): Logger => {
const config = LOGGER_CONFIGS[context];
const prefix = config.color(config.prefix);
return {
log: (message: string) => console.log(`${prefix} ${message}`),
success: (message: string) =>
console.log(`${prefix} ${chalk.green(message)}`),
error: (message: string) =>
console.error(`${prefix} ${chalk.red(message)}`),
warn: (message: string) =>
console.log(`${prefix} ${chalk.yellow(message)}`),
};
};
@@ -1,4 +1,5 @@
import { type FileFolder } from 'twenty-shared/types';
import { type Location } from 'esbuild';
export interface RestartableWatcher {
restart(sourcePaths: string[]): Promise<void>;
@@ -14,7 +15,9 @@ export type OnFileBuiltCallback = (options: {
checksum: string;
}) => void | Promise<void>;
export type OnBuildErrorCallback = (errors: string[]) => void | Promise<void>;
export type OnBuildErrorCallback = (
errors: { error: string; location: Location | null }[],
) => void | Promise<void>;
export type RestartableWatcherOptions = {
appPath: string;
@@ -4,7 +4,6 @@ import {
type Application,
type ApplicationVariables,
} from 'twenty-shared/application';
import { createLogger } from '../../common/logger';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import {
type EntityBuildResult,
@@ -14,8 +13,6 @@ import {
} from '@/cli/utilities/build/manifest/entities/entity-interface';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
const logger = createLogger('manifest-builder');
const findApplicationConfigPath = async (appPath: string): Promise<string> => {
const files = await glob('**/application.config.ts', {
cwd: appPath,
@@ -68,12 +65,6 @@ export class ApplicationEntityBuilder
}
}
display(applications: Application[]): void {
const application = applications[0];
const appName = application?.displayName ?? 'Application';
logger.success(`✓ Loaded "${appName}"`);
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const application = manifest.application;
@@ -19,6 +19,5 @@ export type EntityBuildResult<TManifest> = {
export type ManifestEntityBuilder<EntityManifest> = {
build(appPath: string): Promise<EntityBuildResult<EntityManifest>>;
validate(data: EntityManifest[], errors: ValidationError[]): void;
display(data: EntityManifest[]): void;
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[];
};
@@ -1,6 +1,5 @@
import { glob } from 'fast-glob';
import { type FrontComponentManifest } from 'twenty-shared/application';
import { createLogger } from '@/cli/utilities/build/common/logger';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
@@ -11,8 +10,6 @@ import {
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
const logger = createLogger('manifest-builder');
type FrontComponentConfig = Omit<
FrontComponentManifest,
| 'sourceComponentPath'
@@ -89,18 +86,6 @@ export class FrontComponentEntityBuilder
}
}
display(components: FrontComponentManifest[]): void {
logger.success(`✓ Found ${components.length} front component(s)`);
if (components.length > 0) {
logger.log('📍 Entry points:');
for (const component of components) {
const name = component.name || component.universalIdentifier;
logger.log(` - ${name} (${component.sourceComponentPath})`);
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const components = manifest.frontComponents ?? [];
@@ -1,6 +1,5 @@
import { glob } from 'fast-glob';
import { type ServerlessFunctionManifest } from 'twenty-shared/application';
import { createLogger } from '@/cli/utilities/build/common/logger';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
@@ -11,8 +10,6 @@ import {
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
const logger = createLogger('manifest-builder');
type ExtractedFunctionManifest = Omit<
ServerlessFunctionManifest,
'sourceHandlerPath' | 'builtHandlerPath' | 'builtHandlerChecksum'
@@ -148,18 +145,6 @@ export class FunctionEntityBuilder
}
}
display(functions: ServerlessFunctionManifest[]): void {
logger.success(`✓ Found ${functions.length} function(s)`);
if (functions.length > 0) {
logger.log('📍 Entry points:');
for (const fn of functions) {
const name = fn.name || fn.universalIdentifier;
logger.log(` - ${name} (${fn.sourceHandlerPath})`);
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const functions = manifest.functions ?? [];
@@ -131,10 +131,6 @@ export class ObjectExtensionEntityBuilder
}
}
display(_extensions: ObjectExtensionManifest[]): void {
// Object extensions don't have a dedicated display - they're part of the manifest
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const extensions = manifest.objectExtensions ?? [];
const objects = manifest.objects ?? [];
@@ -2,7 +2,6 @@ import { glob } from 'fast-glob';
import { type ObjectManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { createLogger } from '@/cli/utilities/build/common/logger';
import { manifestExtractFromFileServer } from '@/cli/utilities/build/manifest/manifest-extract-from-file-server';
import { type ValidationError } from '@/cli/utilities/build/manifest/manifest-types';
import {
@@ -12,8 +11,6 @@ import {
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
const logger = createLogger('manifest-builder');
export class ObjectEntityBuilder
implements ManifestEntityBuilder<ObjectManifest>
{
@@ -113,10 +110,6 @@ export class ObjectEntityBuilder
}
}
display(objects: ObjectManifest[]): void {
logger.success(`✓ Found ${objects.length} object(s)`);
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const objects = manifest.objects ?? [];
@@ -8,9 +8,6 @@ import {
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from '@/cli/utilities/build/manifest/entities/entity-interface';
import { createLogger } from '@/cli/utilities/build/common/logger';
const logger = createLogger('manifest-builder');
export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest> {
async build(appPath: string): Promise<EntityBuildResult<RoleManifest>> {
@@ -66,10 +63,6 @@ export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest> {
}
}
display(roles: RoleManifest[]): void {
logger.success(`✓ Found ${roles?.length ?? 'no'} role(s)`);
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const roles = manifest.roles ?? [];
@@ -1,4 +1,5 @@
import chokidar, { type FSWatcher } from 'chokidar';
import { type EventName } from 'chokidar/handler.js';
export type ManifestWatcherOptions = {
appPath: string;
@@ -7,7 +8,7 @@ export type ManifestWatcherOptions = {
export class ManifestWatcher {
private appPath: string;
private handleChangeDetected: (filePath: string) => void;
private handleChangeDetected: (filePath: string, event: EventName) => void;
private watcher: FSWatcher | null = null;
constructor(options: ManifestWatcherOptions) {
@@ -29,7 +30,7 @@ export class ManifestWatcher {
if (event === 'addDir') {
return;
}
this.handleChangeDetected(filePath);
this.handleChangeDetected(filePath, event);
});
}
@@ -1,4 +1,3 @@
import { createLogger } from '@/cli/utilities/build/common/logger';
import {
type ManifestBuildResult,
runManifestBuild,
@@ -9,13 +8,16 @@ import { ApiService } from '@/cli/utilities/api/api-service';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { type FileFolder } from 'twenty-shared/types';
import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate';
const logger = createLogger('dev-mode');
import type { Location } from 'esbuild';
import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
import { relative } from 'path';
import { type EventName } from 'chokidar/handler.js';
export type DevModeOrchestratorOptions = {
appPath: string;
debounceMs?: number;
handleManifestBuilt: (result: ManifestBuildResult) => void | Promise<void>;
uiStateManager: DevUiStateManager;
};
export class DevModeOrchestrator {
@@ -24,16 +26,24 @@ export class DevModeOrchestrator {
private builtFileInfos = new Map<
string,
{ checksum: string; builtPath: string; fileFolder: FileFolder }
{
checksum: string;
builtPath: string;
sourcePath: string;
fileFolder: FileFolder;
}
>();
private fileUploader: FileUploader | null = null;
private apiService = new ApiService();
private apiService = new ApiService({ disableInterceptors: true });
private activeUploads = new Set<Promise<void>>();
private syncTimer: NodeJS.Timeout | null = null;
private isSyncing = false;
private uiStateManager: DevUiStateManager;
private serverChecked = false;
private serverCheckedLogged = false;
private handleManifestBuilt: (
result: ManifestBuildResult,
@@ -43,57 +53,132 @@ export class DevModeOrchestrator {
this.appPath = options.appPath;
this.debounceMs = options.debounceMs ?? 200;
this.handleManifestBuilt = options.handleManifestBuilt;
this.uiStateManager = options.uiStateManager;
}
async handleChangeDetected(filePath: string) {
logger.log(`File changed: ${filePath}`);
private async checkServer(): Promise<void> {
this.serverChecked = await this.apiService.validateAuth();
if (!this.serverChecked && !this.serverCheckedLogged) {
this.uiStateManager.addEvent({
message:
'Please check your server is up and your credentials are correct: "yarn auth:login"',
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
this.serverCheckedLogged = true;
}
}
async handleChangeDetected(sourcePath: string, event: EventName) {
if (!this.serverChecked) {
await this.checkServer();
}
if (!this.serverChecked) {
return;
}
const normalizedSourcePath = this.normalizeFilePath(sourcePath);
this.uiStateManager.addEvent({
message: `Change detected: ${normalizedSourcePath}`,
status: 'info',
});
if (event === 'unlink') {
this.uiStateManager.removeEntity(normalizedSourcePath);
} else {
this.uiStateManager.updateFileStatus(normalizedSourcePath, 'building');
}
this.scheduleSync();
}
handleFileBuildError(errors: string[]): void {
logger.error(`Build failed:`);
handleFileBuildError(
errors: { error: string; location: Location | null }[],
): void {
this.uiStateManager.addEvent({
message: 'Build failed:',
status: 'error',
});
for (const error of errors) {
logger.error(` ${error}`);
this.uiStateManager.addEvent({
message: error.error,
status: 'error',
});
}
}
handleFileBuilt({
fileFolder,
builtPath,
filePath,
sourcePath,
checksum,
}: {
fileFolder: FileFolder;
builtPath: string;
filePath: string;
sourcePath: string;
checksum: string;
}): void {
logger.success(`✓ Successfully built ${filePath}`);
this.uiStateManager.addEvent({
message: `Successfully built ${builtPath}`,
status: 'success',
});
this.builtFileInfos.set(builtPath, { checksum, builtPath, fileFolder });
this.builtFileInfos.set(builtPath, {
checksum,
builtPath,
sourcePath,
fileFolder,
});
if (this.fileUploader) {
this.uploadFile(builtPath, fileFolder);
this.uploadFile(builtPath, sourcePath, fileFolder);
}
this.scheduleSync();
}
private uploadFile(builtPath: string, fileFolder: FileFolder): void {
logger.log(`Uploading ${builtPath}...`);
private normalizeFilePath(filePath: string): string {
return relative(this.appPath, filePath);
}
private uploadFile(
builtPath: string,
sourcePath: string,
fileFolder: FileFolder,
): void {
this.uiStateManager.addEvent({
message: `Uploading ${builtPath}`,
status: 'info',
});
this.uiStateManager.updateFileStatus(sourcePath, 'uploading');
const uploadPromise = this.fileUploader!.uploadFile({
builtPath,
fileFolder,
})
.then((result) => {
if (result.success) {
logger.success(`Successfully uploaded ${builtPath}`);
this.uiStateManager.addEvent({
message: `Successfully uploaded ${builtPath}`,
status: 'success',
});
this.uiStateManager.updateFileStatus(sourcePath, 'success');
} else {
logger.error(`Failed to upload ${builtPath}: ${result.error}`);
this.uiStateManager.addEvent({
message: `Failed to upload ${builtPath}: ${result.error}`,
status: 'error',
});
}
})
.catch((error) => {
logger.error(`Upload failed for ${builtPath}: ${error}`);
this.uiStateManager.addEvent({
message: `Upload failed for ${builtPath}: ${error}`,
status: 'error',
});
})
.finally(() => {
this.activeUploads.delete(uploadPromise);
@@ -126,24 +211,47 @@ export class DevModeOrchestrator {
this.isSyncing = true;
try {
logger.log(`Building manifest...`);
this.uiStateManager.addEvent({
message: 'Building manifest',
status: 'info',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'building',
});
const result = await runManifestBuild(this.appPath);
if (result.error || !result.manifest) {
logger.error(
`Failed to build manifest: ${result.error ?? 'Unknown error'}`,
);
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
this.uiStateManager.addEvent({
message: result.error ?? 'Unknown error',
status: 'error',
});
return;
}
const validation = validateManifest(result.manifest);
this.uiStateManager.updateManifestState({
appName: result.manifest.application.displayName,
});
this.uiStateManager.updateAllFilesTypes({
manifestFilePaths: result.filePaths,
});
if (!validation.isValid) {
const messages = validation.errors
.map((e) => `${e.path}: ${e.message}`)
.join('\n');
logger.error(`Invalid manifest:\n${messages}`);
for (const e of validation.errors) {
this.uiStateManager.addEvent({
message: `${e.path}: ${e.message}`,
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
}
return;
}
@@ -151,11 +259,17 @@ export class DevModeOrchestrator {
if (validation.warnings.length > 0) {
for (const warning of validation.warnings) {
const path = warning.path ? `${warning.path}: ` : '';
logger.warn(`${path}${warning.message}`);
this.uiStateManager.addEvent({
message: `${path}${warning.message}`,
status: 'warning',
});
}
}
logger.success(`Successfully built manifest`);
this.uiStateManager.addEvent({
message: 'Successfully built manifest',
status: 'success',
});
await this.handleManifestBuilt(result);
@@ -167,9 +281,9 @@ export class DevModeOrchestrator {
});
for (const [
builtPath,
{ fileFolder },
{ fileFolder, sourcePath },
] of this.builtFileInfos.entries()) {
this.uploadFile(builtPath, fileFolder);
this.uploadFile(builtPath, sourcePath, fileFolder);
}
}
@@ -181,21 +295,54 @@ export class DevModeOrchestrator {
manifest: result.manifest,
builtFileInfos: this.builtFileInfos,
});
this.uiStateManager.addEvent({
message: 'Manifest checksums set',
status: 'info',
});
await writeManifestToOutput(this.appPath, manifest);
logger.log('Syncing...');
this.uiStateManager.addEvent({
message: 'Manifest saved to output directory',
status: 'info',
});
this.uiStateManager.addEvent({
message: 'Syncing manifest',
status: 'info',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'syncing',
});
const syncResult = await this.apiService.syncApplication(manifest);
this.uiStateManager.updateAllFilesStatus('success');
if (syncResult.success) {
logger.success('✓ Synced');
this.uiStateManager.addEvent({
message: '✓ Synced',
status: 'success',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'synced',
});
} else {
logger.error(
`Sync failed: ${JSON.stringify(syncResult.error, null, 2)}`,
);
this.uiStateManager.addEvent({
message: `Sync failed: ${JSON.stringify(syncResult.error, null, 2)}`,
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
}
} catch (error) {
logger.error(`✗ Sync failed: ${JSON.stringify(error)}`);
this.uiStateManager.addEvent({
message: `Sync failed: ${JSON.stringify(error, null, 2)}`,
status: 'error',
});
this.uiStateManager.updateManifestState({
manifestStatus: 'error',
});
} finally {
this.isSyncing = false;
}
@@ -0,0 +1,177 @@
import { SyncableEntity } from 'twenty-shared/application';
import {
type FileStatus,
type Listener,
type ManifestStatus,
type UiEvent,
type DevUiState,
} from '@/cli/utilities/dev/dev-ui-state';
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-build';
const MAX_EVENT_NUMBER = 200;
export class DevUiStateManager {
private state: DevUiState;
private eventIdCounter = 0;
private listeners = new Set<Listener>();
constructor({
appPath,
frontendUrl,
}: {
appPath: string;
frontendUrl?: string;
}) {
this.state = {
appPath,
frontendUrl,
appName: null,
appDescription: null,
appUniversalIdentifier: null,
manifestStatus: 'idle',
entities: new Map(),
events: [],
};
}
getSnapshot(): DevUiState {
return this.state;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this.getSnapshot());
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) {
listener(this.state);
}
}
addEvent({
message,
status = 'info',
}: {
message: string;
status: UiEvent['status'];
}): void {
const event: UiEvent = {
id: ++this.eventIdCounter,
timestamp: new Date(),
message,
status,
};
this.state = {
...this.state,
events: [...this.state.events.slice(-MAX_EVENT_NUMBER - 1), event],
};
this.notify();
}
updateManifestState({
manifestStatus,
appName,
}: {
manifestStatus?: ManifestStatus;
appName?: string;
}): void {
this.state = {
...this.state,
...(manifestStatus ? { manifestStatus } : {}),
...(appName ? { appName } : {}),
};
this.notify();
}
convertEntityTypeToSyncableEntity(
entityType: string,
): SyncableEntity | undefined {
switch (entityType) {
case 'objects':
return SyncableEntity.Object;
case 'objectExtensions':
return SyncableEntity.ObjectExtension;
case 'functions':
return SyncableEntity.Function;
case 'frontComponents':
return SyncableEntity.FrontComponent;
case 'roles':
return SyncableEntity.Role;
default:
return;
}
}
updateAllFilesTypes({
manifestFilePaths,
}: {
manifestFilePaths: EntityFilePaths;
}): void {
const entityMaps = new Map<string, SyncableEntity>();
(Object.entries(manifestFilePaths) as [SyncableEntity, string[]][]).forEach(
([entityType, filePaths]) => {
filePaths.forEach((filePath) => {
const syncableEntity =
this.convertEntityTypeToSyncableEntity(entityType);
if (!syncableEntity) {
return;
}
entityMaps.set(filePath, syncableEntity);
});
},
);
const entities = new Map(this.state.entities);
for (const [filePath, entity] of entities) {
entities.set(filePath, {
...entity,
type: entityMaps.get(filePath),
});
}
this.state = { ...this.state, entities };
this.notify();
}
updateAllFilesStatus(status: FileStatus): void {
const entities = new Map(this.state.entities);
for (const [filePath, entity] of entities) {
entities.set(filePath, {
...entity,
status: status,
});
}
this.state = { ...this.state, entities };
this.notify();
}
removeEntity(filePath: string) {
const entities = new Map(this.state.entities);
entities.delete(filePath);
this.state = { ...this.state, entities };
}
updateFileStatus(filePath: string, status: FileStatus): void {
const entities = new Map(this.state.entities);
entities.set(filePath, {
name: filePath,
path: filePath,
status: status,
});
this.state = { ...this.state, entities };
this.notify();
}
}
@@ -0,0 +1,37 @@
import { type SyncableEntity } from 'twenty-shared/application';
export type UiEvent = {
id: number;
timestamp: Date;
message: string;
status: 'info' | 'success' | 'error' | 'warning';
};
export type ManifestStatus =
| 'idle'
| 'building'
| 'syncing'
| 'synced'
| 'error';
export type FileStatus = 'pending' | 'building' | 'uploading' | 'success';
export type EntityInfo = {
name: string;
path: string;
type?: SyncableEntity;
status: FileStatus;
};
export type DevUiState = {
appPath: string;
appName: string | null;
appDescription: string | null;
appUniversalIdentifier: string | null;
frontendUrl?: string | null;
manifestStatus: ManifestStatus;
entities: Map<string, EntityInfo>;
events: UiEvent[];
};
export type Listener = (state: DevUiState) => void;
@@ -0,0 +1,293 @@
import {
type UiEvent,
type DevUiState,
type FileStatus,
type EntityInfo,
} from '@/cli/utilities/dev/dev-ui-state';
import { SyncableEntity } from 'twenty-shared/application';
import { type DevUiStateManager } from '@/cli/utilities/dev/dev-ui-state-manager';
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const UPLOAD_FRAMES = ['↑', '⇡', '↟', '⤒'];
const STATUS_ICONS: Record<FileStatus, string> = {
pending: '○',
building: '◐',
uploading: '↑',
success: '✓',
};
const STATUS_COLORS: Record<FileStatus, string> = {
pending: 'gray',
building: 'yellow',
uploading: 'cyan',
success: 'green',
};
const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Object]: 'Objects',
[SyncableEntity.ObjectExtension]: 'Object Extensions',
[SyncableEntity.Function]: 'Functions',
[SyncableEntity.FrontComponent]: 'Front Components',
[SyncableEntity.Role]: 'Roles',
};
const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
const EVENT_COLORS: Record<UiEvent['status'], string> = {
info: 'gray',
success: 'green',
error: 'red',
warning: 'yellow',
};
const groupEntitiesByType = (
entities: Map<string, EntityInfo>,
): Map<SyncableEntity, EntityInfo[]> => {
const grouped = new Map<SyncableEntity, EntityInfo[]>();
for (const type of ENTITY_ORDER) {
grouped.set(type, []);
}
for (const entity of entities.values()) {
if (!entity.type) {
continue;
}
const list = grouped.get(entity.type) ?? [];
list.push(entity);
grouped.set(entity.type, list);
}
return grouped;
};
const formatTime = (date: Date): string => {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const shortenPath = (path: string, maxLength = 40): string => {
if (path.length <= maxLength) return path;
const parts = path.split('/');
if (parts.length <= 2) return path;
return `.../${parts.slice(-2).join('/')}`;
};
const getApplicationUrl = (snapshot: DevUiState): string | null => {
if (!snapshot.frontendUrl || !snapshot.appUniversalIdentifier) {
return null;
}
return `${snapshot.frontendUrl}/settings/applications`;
};
export const renderDevUI = async (
uiStateManager: DevUiStateManager,
): Promise<{ unmount: () => void }> => {
const [React, ink] = await Promise.all([import('react'), import('ink')]);
const { useState, useEffect } = React;
const { render, Box, Text, Static } = ink;
const useSpinner = (frames: string[], interval = 80): string => {
const [frameIndex, setFrameIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrameIndex((prev) => (prev + 1) % frames.length);
}, interval);
return () => clearInterval(timer);
}, [frames.length, interval]);
return frames[frameIndex];
};
const EventItem = ({ event }: { event: UiEvent }): React.ReactElement => {
const color = EVENT_COLORS[event.status];
const time = formatTime(event.timestamp);
return (
<Box>
<Text dimColor>{time} </Text>
<Text color={color}>{event.message}</Text>
</Box>
);
};
const StatusIcon = ({
status,
}: {
status: FileStatus;
}): React.ReactElement => {
const buildingFrame = useSpinner(SPINNER_FRAMES, 200);
const uploadingFrame = useSpinner(UPLOAD_FRAMES, 200);
const iconByStatus: Record<FileStatus, string> = {
building: buildingFrame,
uploading: uploadingFrame,
pending: STATUS_ICONS.pending,
success: STATUS_ICONS.success,
};
return <Text color={STATUS_COLORS[status]}>{iconByStatus[status]} </Text>;
};
const EntityRow = ({
entity,
}: {
entity: EntityInfo;
}): React.ReactElement => {
return (
<Box>
<StatusIcon status={entity.status} />
<Text>{entity.name}</Text>
{entity.path !== entity.name && (
<Text dimColor> ({shortenPath(entity.path)})</Text>
)}
</Box>
);
};
const EntitySection = ({
type,
entities,
}: {
type: SyncableEntity;
entities: EntityInfo[];
}): React.ReactElement | null => {
if (entities.length === 0) return null;
return (
<Box flexDirection="column" marginTop={1}>
<Text bold dimColor>
{ENTITY_LABELS[type]}
</Text>
{entities.map((entity) => (
<EntityRow key={entity.path} entity={entity} />
))}
</Box>
);
};
const MANIFEST_STATUS_CONFIG = {
synced: { color: 'green', icon: '✓', text: 'Synced' },
building: { color: 'yellow', icon: null, text: 'Building...' },
syncing: { color: 'yellow', icon: null, text: 'Syncing...' },
error: { color: 'red', icon: 'x', text: 'Error' },
idle: { color: 'gray', icon: 'o', text: 'Idle' },
} as const;
const UnifiedStatusIndicator = ({
snapshot,
}: {
snapshot: DevUiState;
}): React.ReactElement => {
const spinnerFrame = useSpinner(SPINNER_FRAMES, 80);
const config = MANIFEST_STATUS_CONFIG[snapshot.manifestStatus];
const icon = config.icon ?? spinnerFrame;
return (
<Text color={config.color}>
{icon} {config.text}
</Text>
);
};
const ApplicationPanel = ({
snapshot,
}: {
snapshot: DevUiState;
}): React.ReactElement => {
const groupedEntities = groupEntitiesByType(snapshot.entities);
const appUrl = getApplicationUrl(snapshot);
return (
<Box
flexDirection="column"
borderStyle="classic"
borderColor="gray"
paddingX={1}
>
<Text bold color="cyan">
Application
</Text>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text dimColor>Name: </Text>
<Text bold>{snapshot.appName ?? 'Loading...'}</Text>
</Box>
{snapshot.appDescription && (
<Box>
<Text dimColor>Description: </Text>
<Text>{snapshot.appDescription}</Text>
</Box>
)}
<Box>
<Text dimColor>Status: </Text>
<UnifiedStatusIndicator snapshot={snapshot} />
</Box>
{appUrl && (
<Box>
<Text dimColor>Open:</Text>
<Text bold color="cyan">
{' '}
{appUrl}
</Text>
</Box>
)}
</Box>
<Box marginLeft={2} flexDirection="column">
{ENTITY_ORDER.map((type) => {
const entities = groupedEntities.get(type) ?? [];
return <EntitySection key={type} type={type} entities={entities} />;
})}
</Box>
</Box>
);
};
const Legend = (): React.ReactElement => (
<Box marginTop={1}>
<Text dimColor>
<Text color={STATUS_COLORS.pending}>{STATUS_ICONS.pending}</Text>{' '}
pending <Text color={STATUS_COLORS.building}>{SPINNER_FRAMES[0]}</Text>{' '}
building <Text color={STATUS_COLORS.uploading}>{UPLOAD_FRAMES[0]}</Text>{' '}
uploading{' '}
<Text color={STATUS_COLORS.success}>{STATUS_ICONS.success}</Text>{' '}
success
</Text>
</Box>
);
const DevUI = (): React.ReactElement => {
const [snapshot, setSnapshot] = useState<DevUiState>(
uiStateManager.getSnapshot(),
);
useEffect(() => {
return uiStateManager.subscribe(setSnapshot);
}, []);
return (
<>
<Static items={snapshot.events}>
{(event: UiEvent) => <EventItem key={event.id} event={event} />}
</Static>
<Box marginTop={1} flexDirection="column">
<ApplicationPanel snapshot={snapshot} />
<Legend />
</Box>
</>
);
};
const { unmount } = render(<DevUI />);
return { unmount };
};