Project actions: run commands from header on web + mobile, with SSH-forward URL opening (#542)
* feat: add project actions in header with reliable terminal run flow - Add per-project Actions settings with icons, platform filters, and default action - Run/stop actions from header using terminal tabs, including Ctrl+C then force-kill fallback - Improve terminal UX with stable selection, resize handling, and smarter URL auto-open for localhost ports * feat: add project actions UI with refined header dropdown behavior * feat: add parent-session back button in chat * fix: make web and desktop dev modes reliable and conflict-free - Separate desktop and web dev ports to avoid collisions - Add robust process-tree shutdown so Ctrl+C cleans sidecars - Add true web HMR mode and keep service worker out of dev * fix: linux safe scripts for dev * feat: run project actions on web and add desktop SSH forward URL opening * feat: add mobile project actions button with terminal tabs and tighter tab UI * revert: remove experimental mobile terminal selection UI * feat: show Add action button in header when empty * fix: make retry countdown human-readable in status row * fix: prevent nav rail actions from firing through overlays
This commit is contained in:
committed by
GitHub
parent
d948aa5557
commit
95c71789c4
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
RiBrainAi3Line,
|
||||
RiCheckboxCircleLine,
|
||||
RiBugLine,
|
||||
RiCodeLine,
|
||||
RiCommandLine,
|
||||
RiFileTextLine,
|
||||
RiFlaskLine,
|
||||
RiGitBranchLine,
|
||||
RiHammerLine,
|
||||
RiPlayLine,
|
||||
RiRocketLine,
|
||||
RiRobot2Line,
|
||||
RiSearchLine,
|
||||
RiServerLine,
|
||||
RiSettings3Line,
|
||||
RiStackLine,
|
||||
RiTerminalBoxLine,
|
||||
RiToolsLine,
|
||||
} from '@remixicon/react';
|
||||
import type { ComponentType } from 'react';
|
||||
import type {
|
||||
OpenChamberProjectAction,
|
||||
OpenChamberProjectActionPlatform,
|
||||
} from '@/lib/openchamberConfig';
|
||||
import type {
|
||||
DesktopSshInstance,
|
||||
DesktopSshPortForward,
|
||||
} from '@/lib/desktopSsh';
|
||||
|
||||
export type ProjectActionIconKey =
|
||||
| 'play'
|
||||
| 'build'
|
||||
| 'lint'
|
||||
| 'terminal'
|
||||
| 'tools'
|
||||
| 'bug'
|
||||
| 'flask'
|
||||
| 'rocket'
|
||||
| 'code'
|
||||
| 'server'
|
||||
| 'branch'
|
||||
| 'search'
|
||||
| 'settings'
|
||||
| 'brain'
|
||||
| 'stack'
|
||||
| 'robot'
|
||||
| 'command'
|
||||
| 'file';
|
||||
|
||||
export const PROJECT_ACTION_ICONS: Array<{
|
||||
key: ProjectActionIconKey;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{ key: 'play', label: 'Play', Icon: RiPlayLine },
|
||||
{ key: 'build', label: 'Build', Icon: RiHammerLine },
|
||||
{ key: 'lint', label: 'Lint', Icon: RiCheckboxCircleLine },
|
||||
{ key: 'terminal', label: 'Terminal', Icon: RiTerminalBoxLine },
|
||||
{ key: 'tools', label: 'Tools', Icon: RiToolsLine },
|
||||
{ key: 'bug', label: 'Bug', Icon: RiBugLine },
|
||||
{ key: 'flask', label: 'Flask', Icon: RiFlaskLine },
|
||||
{ key: 'rocket', label: 'Rocket', Icon: RiRocketLine },
|
||||
{ key: 'code', label: 'Code', Icon: RiCodeLine },
|
||||
{ key: 'server', label: 'Server', Icon: RiServerLine },
|
||||
{ key: 'branch', label: 'Branch', Icon: RiGitBranchLine },
|
||||
{ key: 'search', label: 'Search', Icon: RiSearchLine },
|
||||
{ key: 'settings', label: 'Settings', Icon: RiSettings3Line },
|
||||
{ key: 'brain', label: 'Brain', Icon: RiBrainAi3Line },
|
||||
{ key: 'stack', label: 'Stack', Icon: RiStackLine },
|
||||
{ key: 'robot', label: 'Robot', Icon: RiRobot2Line },
|
||||
{ key: 'command', label: 'Command', Icon: RiCommandLine },
|
||||
{ key: 'file', label: 'File', Icon: RiFileTextLine },
|
||||
];
|
||||
|
||||
export const PROJECT_ACTION_ICON_MAP = Object.fromEntries(
|
||||
PROJECT_ACTION_ICONS.map((entry) => [entry.key, entry.Icon])
|
||||
) as Record<ProjectActionIconKey, ComponentType<{ className?: string }>>;
|
||||
|
||||
export const PROJECT_ACTIONS_UPDATED_EVENT = 'openchamber:project-actions-updated';
|
||||
|
||||
export const normalizeProjectActionDirectory = (value: string): string => {
|
||||
const trimmed = (value || '').trim().replace(/\\/g, '/');
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (trimmed === '/') {
|
||||
return '/';
|
||||
}
|
||||
return trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed;
|
||||
};
|
||||
|
||||
export const getCurrentProjectActionPlatform = (): OpenChamberProjectActionPlatform => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'macos';
|
||||
}
|
||||
const ua = (navigator.userAgent || '').toLowerCase();
|
||||
if (ua.includes('windows')) {
|
||||
return 'windows';
|
||||
}
|
||||
if (ua.includes('linux')) {
|
||||
return 'linux';
|
||||
}
|
||||
return 'macos';
|
||||
};
|
||||
|
||||
export const isProjectActionEnabledOnPlatform = (
|
||||
action: OpenChamberProjectAction,
|
||||
platform: OpenChamberProjectActionPlatform
|
||||
): boolean => {
|
||||
if (!Array.isArray(action.platforms) || action.platforms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return action.platforms.includes(platform);
|
||||
};
|
||||
|
||||
export const toProjectActionRunKey = (directory: string, actionId: string): string => {
|
||||
return `${normalizeProjectActionDirectory(directory)}::${actionId}`;
|
||||
};
|
||||
|
||||
export type ProjectActionDesktopForwardOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const toBrowserHost = (host: string | undefined): string => {
|
||||
const value = (host || '').trim();
|
||||
if (!value || value === '0.0.0.0' || value === '::') {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePort = (value: number | undefined): number | null => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 1 || rounded > 65535) {
|
||||
return null;
|
||||
}
|
||||
return rounded;
|
||||
};
|
||||
|
||||
const buildForwardOption = (instance: DesktopSshInstance, forward: DesktopSshPortForward): ProjectActionDesktopForwardOption | null => {
|
||||
if (!forward.enabled || forward.type !== 'local') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localPort = normalizePort(forward.localPort);
|
||||
const remotePort = normalizePort(forward.remotePort);
|
||||
if (!localPort || !remotePort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localHost = toBrowserHost(forward.localHost || instance.localForward.bindHost || '127.0.0.1');
|
||||
const remoteHost = (forward.remoteHost || '127.0.0.1').trim();
|
||||
const instanceLabel = (instance.nickname || instance.id || 'instance').trim();
|
||||
|
||||
return {
|
||||
id: `${instance.id}::${forward.id}`,
|
||||
label: `${instanceLabel} - ${localHost}:${localPort} -> ${remoteHost}:${remotePort}`,
|
||||
url: `http://${localHost}:${localPort}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildProjectActionDesktopForwardOptions = (
|
||||
instances: DesktopSshInstance[]
|
||||
): ProjectActionDesktopForwardOption[] => {
|
||||
const options: ProjectActionDesktopForwardOption[] = [];
|
||||
|
||||
for (const instance of instances) {
|
||||
if (!instance?.id || !Array.isArray(instance.portForwards)) {
|
||||
continue;
|
||||
}
|
||||
for (const forward of instance.portForwards) {
|
||||
const option = buildForwardOption(instance, forward);
|
||||
if (option) {
|
||||
options.push(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const resolveProjectActionDesktopForwardUrl = (
|
||||
selectionId: string | undefined,
|
||||
instances: DesktopSshInstance[]
|
||||
): string | null => {
|
||||
const key = (selectionId || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const options = buildProjectActionDesktopForwardOptions(instances);
|
||||
const matched = options.find((entry) => entry.id === key);
|
||||
return matched?.url || null;
|
||||
};
|
||||
Reference in New Issue
Block a user