feat(workspace): add workspace bottom bar, terminal tab manager, and run-process integration

This commit is contained in:
2026-01-01 08:09:30 +00:00
parent fc2661fb4c
commit 829c09a97b
10 changed files with 1624 additions and 301 deletions

View File

@@ -0,0 +1,491 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import { themeDefaultStyles } from '../../00theme.js';
import type { IExecutionEnvironment } from '../../00group-runtime/index.js';
import '../../dees-icon/dees-icon.js';
import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js';
import type { IRunProcessEventDetail } from '../dees-workspace-terminal/interfaces.js';
declare global {
interface HTMLElementTagNameMap {
'dees-workspace-bottombar': DeesWorkspaceBottombar;
}
}
interface IOutdatedPackage {
name: string;
current: string;
wanted: string;
latest: string;
type: 'dependencies' | 'devDependencies';
}
@customElement('dees-workspace-bottombar')
export class DeesWorkspaceBottombar extends DeesElement {
// INSTANCE
@property({ type: Object })
accessor executionEnvironment: IExecutionEnvironment | null = null;
// Script runner state
@state()
accessor scripts: Record<string, string> = {};
// Package checker state
@state()
accessor packageStatus: 'checking' | 'up-to-date' | 'updates-available' | 'error' | 'idle' = 'idle';
@state()
accessor outdatedPackages: IOutdatedPackage[] = [];
@state()
accessor isCheckingPackages: boolean = false;
public static styles = [
themeDefaultStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
height: 24px;
flex-shrink: 0;
}
.bottom-bar {
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
gap: 4px;
background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 6%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')};
font-size: 11px;
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
}
.widget {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;
}
.widget:hover {
background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 12%)')};
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 80%)')};
}
.widget dees-icon {
flex-shrink: 0;
}
.widget-separator {
width: 1px;
height: 14px;
background: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(0 0% 20%)')};
margin: 0 4px;
}
.widget.running {
color: ${cssManager.bdTheme('hsl(210 100% 45%)', 'hsl(210 100% 60%)')};
}
.widget.up-to-date {
color: ${cssManager.bdTheme('hsl(142 70% 35%)', 'hsl(142 70% 50%)')};
}
.widget.updates-available {
color: ${cssManager.bdTheme('hsl(38 92% 45%)', 'hsl(38 92% 55%)')};
}
.widget.error {
color: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 60%)')};
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinning {
animation: spin 1s linear infinite;
}
.spacer {
flex: 1;
}
`,
];
public render(): TemplateResult {
return html`
<div class="bottom-bar">
<!-- Script Runner Widget -->
<div
class="widget"
@click=${this.handleScriptClick}
title="Run script"
>
<dees-icon
.icon=${'lucide:play'}
iconSize="12"
></dees-icon>
<span>Scripts</span>
</div>
<div class="widget-separator"></div>
<!-- Package Checker Widget -->
<div
class="widget ${this.getPackageStatusClass()}"
@click=${this.handlePackageClick}
title="${this.getPackageTooltip()}"
>
<dees-icon
.icon=${this.getPackageIcon()}
iconSize="12"
class="${this.isCheckingPackages ? 'spinning' : ''}"
></dees-icon>
<span>${this.getPackageStatusText()}</span>
</div>
<div class="spacer"></div>
<!-- Future widgets can be added here -->
</div>
`;
}
async firstUpdated() {
await this.loadScripts();
await this.checkPackages();
}
async updated(changedProperties: Map<string, any>) {
if (changedProperties.has('executionEnvironment') && this.executionEnvironment) {
await this.loadScripts();
await this.checkPackages();
}
}
// ========== Script Runner ==========
private async loadScripts(): Promise<void> {
if (!this.executionEnvironment) return;
try {
const packageJsonExists = await this.executionEnvironment.exists('/package.json');
if (!packageJsonExists) {
this.scripts = {};
return;
}
const content = await this.executionEnvironment.readFile('/package.json');
const packageJson = JSON.parse(content);
this.scripts = packageJson.scripts || {};
} catch (error) {
console.warn('Failed to load scripts from package.json:', error);
this.scripts = {};
}
}
private async handleScriptClick(e: MouseEvent): Promise<void> {
e.stopPropagation();
const scriptNames = Object.keys(this.scripts);
if (scriptNames.length === 0) {
return;
}
const menuItems = scriptNames.map(name => ({
name: name,
iconName: 'lucide:terminal' as const,
action: async () => {
await this.runScript(name);
},
}));
await DeesContextmenu.openContextMenuWithOptions(e, menuItems);
}
private async runScript(scriptName: string): Promise<void> {
if (!this.executionEnvironment) return;
// Emit run-process event for the workspace to create a terminal tab
const detail: IRunProcessEventDetail = {
type: 'script',
label: scriptName,
command: 'pnpm',
args: ['run', scriptName],
metadata: { scriptName },
};
this.dispatchEvent(new CustomEvent('run-process', {
bubbles: true,
composed: true,
detail,
}));
}
// ========== Package Checker ==========
private async checkPackages(): Promise<void> {
if (!this.executionEnvironment) {
this.packageStatus = 'idle';
return;
}
try {
const packageJsonExists = await this.executionEnvironment.exists('/package.json');
if (!packageJsonExists) {
this.packageStatus = 'idle';
return;
}
this.packageStatus = 'checking';
this.isCheckingPackages = true;
// Run pnpm outdated --json
const process = await this.executionEnvironment.spawn('pnpm', ['outdated', '--json']);
let output = '';
await process.output.pipeTo(
new WritableStream({
write: (chunk) => {
output += chunk;
},
})
);
const exitCode = await process.exit;
// pnpm outdated returns exit code 1 if there are outdated packages
if (exitCode === 0) {
// No outdated packages
this.packageStatus = 'up-to-date';
this.outdatedPackages = [];
} else {
// Parse outdated packages
try {
const outdatedData = JSON.parse(output);
this.outdatedPackages = this.parseOutdatedPackages(outdatedData);
this.packageStatus = this.outdatedPackages.length > 0 ? 'updates-available' : 'up-to-date';
} catch {
// If parsing fails but exit code is 1, assume there are updates
this.packageStatus = 'updates-available';
this.outdatedPackages = [];
}
}
} catch (error) {
console.warn('Failed to check for package updates:', error);
this.packageStatus = 'error';
} finally {
this.isCheckingPackages = false;
}
}
private parseOutdatedPackages(data: any): IOutdatedPackage[] {
const packages: IOutdatedPackage[] = [];
// pnpm outdated --json returns an object with package names as keys
if (typeof data === 'object' && data !== null) {
for (const [name, info] of Object.entries(data)) {
const pkgInfo = info as any;
packages.push({
name,
current: pkgInfo.current || 'unknown',
wanted: pkgInfo.wanted || pkgInfo.current || 'unknown',
latest: pkgInfo.latest || pkgInfo.wanted || 'unknown',
type: pkgInfo.dependencyType === 'devDependencies' ? 'devDependencies' : 'dependencies',
});
}
}
return packages;
}
private async handlePackageClick(e: MouseEvent): Promise<void> {
e.stopPropagation();
if (this.isCheckingPackages) return;
const menuItems: Parameters<typeof DeesContextmenu.openContextMenuWithOptions>[1] = [];
// Refresh option - show output in terminal
menuItems.push({
name: 'Check for updates',
iconName: 'lucide:refreshCw',
action: async () => {
// Create terminal tab to show pnpm outdated output
const detail: IRunProcessEventDetail = {
type: 'package-update',
label: 'check packages',
command: 'pnpm',
args: ['outdated'],
};
this.dispatchEvent(new CustomEvent('run-process', {
bubbles: true,
composed: true,
detail,
}));
// Also refresh the widget status silently after a delay
setTimeout(() => this.checkPackages(), 3000);
},
});
if (this.outdatedPackages.length > 0) {
menuItems.push({ divider: true });
// Show outdated packages (max 10)
const displayPackages = this.outdatedPackages.slice(0, 10);
for (const pkg of displayPackages) {
menuItems.push({
name: `${pkg.name}: ${pkg.current}${pkg.latest}`,
iconName: 'lucide:package',
action: async () => {
// Update single package
await this.updatePackage(pkg.name);
},
});
}
if (this.outdatedPackages.length > 10) {
menuItems.push({
name: `... and ${this.outdatedPackages.length - 10} more`,
iconName: 'lucide:moreHorizontal',
action: async () => {},
});
}
menuItems.push({ divider: true });
menuItems.push({
name: 'Update all packages',
iconName: 'lucide:arrowUpCircle',
action: async () => {
await this.updateAllPackages();
},
});
}
await DeesContextmenu.openContextMenuWithOptions(e, menuItems);
}
private async updatePackage(packageName: string): Promise<void> {
if (!this.executionEnvironment) return;
// Emit run-process event for the workspace to create a terminal tab
const detail: IRunProcessEventDetail = {
type: 'package-update',
label: `update ${packageName}`,
command: 'pnpm',
args: ['update', packageName],
metadata: { packageName },
};
this.dispatchEvent(new CustomEvent('run-process', {
bubbles: true,
composed: true,
detail,
}));
}
private async updateAllPackages(): Promise<void> {
if (!this.executionEnvironment) return;
// Emit run-process event for the workspace to create a terminal tab
const detail: IRunProcessEventDetail = {
type: 'package-update',
label: 'update all',
command: 'pnpm',
args: ['update'],
};
this.dispatchEvent(new CustomEvent('run-process', {
bubbles: true,
composed: true,
detail,
}));
}
// ========== Helper Methods ==========
private getPackageStatusClass(): string {
if (this.isCheckingPackages) return '';
return this.packageStatus;
}
private getPackageIcon(): string {
if (this.isCheckingPackages) return 'lucide:loader2';
switch (this.packageStatus) {
case 'up-to-date':
return 'lucide:checkCircle';
case 'updates-available':
return 'lucide:alertCircle';
case 'error':
return 'lucide:xCircle';
default:
return 'lucide:package';
}
}
private getPackageStatusText(): string {
if (this.isCheckingPackages) return 'Checking...';
switch (this.packageStatus) {
case 'up-to-date':
return 'Up to date';
case 'updates-available':
return `${this.outdatedPackages.length} update${this.outdatedPackages.length !== 1 ? 's' : ''}`;
case 'error':
return 'Check failed';
default:
return 'Packages';
}
}
private getPackageTooltip(): string {
if (this.isCheckingPackages) return 'Checking for package updates...';
switch (this.packageStatus) {
case 'up-to-date':
return 'All packages are up to date';
case 'updates-available':
return `${this.outdatedPackages.length} package update${this.outdatedPackages.length !== 1 ? 's' : ''} available`;
case 'error':
return 'Failed to check for updates. Click to retry.';
default:
return 'Click to check for package updates';
}
}
// ========== Public Methods ==========
/**
* Manually trigger a package check
*/
public async refreshPackageStatus(): Promise<void> {
await this.checkPackages();
}
/**
* Manually reload scripts from package.json
*/
public async refreshScripts(): Promise<void> {
await this.loadScripts();
}
}

View File

@@ -0,0 +1 @@
export * from './dees-workspace-bottombar.js';