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
+2
-1
@@ -38,7 +38,8 @@
|
||||
"postinstall": "patch-package",
|
||||
"dev:web": "bun run --cwd packages/web build:watch",
|
||||
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
|
||||
"dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"",
|
||||
"dev:web:full": "node ./scripts/dev-web-full.mjs",
|
||||
"dev:web:hmr": "node ./scripts/dev-web-hmr.mjs",
|
||||
"start:web": "bun run --cwd packages/web start",
|
||||
"pack:web": "bun pm pack --cwd packages/web",
|
||||
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||
|
||||
@@ -13,10 +13,70 @@ function spawnProcess(command, args, opts = {}) {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
stdio: 'inherit',
|
||||
detached: process.platform !== 'win32',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tauriProcess = spawnProcess('bun', [
|
||||
'--cwd',
|
||||
@@ -37,19 +97,7 @@ async function main() {
|
||||
}
|
||||
cleaning = true;
|
||||
|
||||
const stopChild = (child, label) => {
|
||||
if (!child || child.killed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill('SIGINT');
|
||||
} catch (error) {
|
||||
console.warn(`[desktop:dev] Failed to stop ${label}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
stopChild(tauriProcess, 'Tauri dev process');
|
||||
|
||||
await stopChildTree(tauriProcess);
|
||||
process.exit(typeof code === 'number' ? code : 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DESKTOP_DEV_PORT = 3901;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -48,11 +50,12 @@ const run = (cmd, args, cwd) => {
|
||||
console.log('[desktop] ensuring sidecar + web-dist...');
|
||||
run('node', ['./scripts/build-sidecar.mjs'], desktopDir);
|
||||
|
||||
console.log('[desktop] starting API server on http://127.0.0.1:3001 ...');
|
||||
console.log(`[desktop] starting API server on http://127.0.0.1:${DESKTOP_DEV_PORT} ...`);
|
||||
|
||||
const apiChild = spawn(sidecarPath, ['--port', '3001'], {
|
||||
const apiChild = spawn(sidecarPath, ['--port', String(DESKTOP_DEV_PORT)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCHAMBER_HOST: '127.0.0.1',
|
||||
@@ -67,9 +70,10 @@ console.log('[desktop] starting Vite HMR server on http://127.0.0.1:5173 ...');
|
||||
const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], {
|
||||
cwd: webDir,
|
||||
stdio: 'inherit',
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCHAMBER_PORT: process.env.OPENCHAMBER_PORT || '3001',
|
||||
OPENCHAMBER_PORT: String(DESKTOP_DEV_PORT),
|
||||
NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1',
|
||||
no_proxy: process.env.no_proxy || 'localhost,127.0.0.1',
|
||||
},
|
||||
@@ -77,17 +81,80 @@ const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '51
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdown = () => {
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestApiShutdown() {
|
||||
const url = `http://127.0.0.1:${DESKTOP_DEV_PORT}/api/system/shutdown`;
|
||||
try {
|
||||
await fetch(url, { method: 'POST' });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const shutdown = async (exitCode = 0) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
try {
|
||||
apiChild.kill('SIGTERM');
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
webChild.kill('SIGTERM');
|
||||
} catch {}
|
||||
await requestApiShutdown();
|
||||
await Promise.all([stopChildTree(webChild), stopChildTree(apiChild)]);
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
const handleExit = (label) => (code, signal) => {
|
||||
@@ -99,8 +166,10 @@ const handleExit = (label) => (code, signal) => {
|
||||
console.error(`[desktop] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
|
||||
}
|
||||
|
||||
shutdown();
|
||||
process.exit(typeof code === 'number' ? code : 1);
|
||||
shutdown(typeof code === 'number' ? code : 1).catch((error) => {
|
||||
console.error('[desktop] shutdown failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
apiChild.on('exit', handleExit('API server'));
|
||||
@@ -111,13 +180,18 @@ const handleError = (label) => (error) => {
|
||||
return;
|
||||
}
|
||||
console.error(`[desktop] failed to start ${label}:`, error);
|
||||
shutdown();
|
||||
process.exit(1);
|
||||
shutdown(1).catch(() => process.exit(1));
|
||||
};
|
||||
|
||||
apiChild.on('error', handleError('API server'));
|
||||
webChild.on('error', handleError('Vite server'));
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('exit', shutdown);
|
||||
process.on('SIGINT', () => {
|
||||
shutdown(130).catch(() => process.exit(130));
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown(143).catch(() => process.exit(143));
|
||||
});
|
||||
process.on('SIGHUP', () => {
|
||||
shutdown(129).catch(() => process.exit(129));
|
||||
});
|
||||
|
||||
@@ -2732,8 +2732,8 @@ fn main() {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let local_url = if cfg!(debug_assertions) {
|
||||
let dev_url = "http://127.0.0.1:3001";
|
||||
if wait_for_health(dev_url).await {
|
||||
let dev_url = "http://127.0.0.1:3901".to_string();
|
||||
if wait_for_health(&dev_url).await {
|
||||
dev_url.to_string()
|
||||
} else {
|
||||
match spawn_local_server(&handle).await {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
||||
"beforeBuildCommand": "bun run build:sidecar",
|
||||
"devUrl": "http://127.0.0.1:3001",
|
||||
"devUrl": "http://127.0.0.1:3901",
|
||||
"frontendDist": "../noop-dist"
|
||||
},
|
||||
"app": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownLine } from '@remixicon/react';
|
||||
import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { getMemoryLimits } from '@/stores/types/sessionTypes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
@@ -76,6 +77,7 @@ export const ChatContainer: React.FC = () => {
|
||||
loadMoreMessages,
|
||||
updateViewportAnchor,
|
||||
openNewSessionDraft,
|
||||
setCurrentSession,
|
||||
trimToViewportWindow,
|
||||
newSessionDraft,
|
||||
} = useSessionStore(
|
||||
@@ -86,6 +88,7 @@ export const ChatContainer: React.FC = () => {
|
||||
loadMoreMessages: state.loadMoreMessages,
|
||||
updateViewportAnchor: state.updateViewportAnchor,
|
||||
openNewSessionDraft: state.openNewSessionDraft,
|
||||
setCurrentSession: state.setCurrentSession,
|
||||
trimToViewportWindow: state.trimToViewportWindow,
|
||||
newSessionDraft: state.newSessionDraft,
|
||||
}))
|
||||
@@ -112,6 +115,8 @@ export const ChatContainer: React.FC = () => {
|
||||
)
|
||||
);
|
||||
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
|
||||
const blockingRequestState = useSessionStore(
|
||||
useShallow((state) => ({
|
||||
sessions: state.sessions,
|
||||
@@ -168,6 +173,42 @@ export const ChatContainer: React.FC = () => {
|
||||
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
|
||||
const parentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = sessions.find((session) => session.id === currentSessionId);
|
||||
const parentID = current?.parentID;
|
||||
if (!parentID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessions.find((session) => session.id === parentID) ?? null;
|
||||
}, [currentSessionId, sessions]);
|
||||
|
||||
const handleReturnToParentSession = React.useCallback(() => {
|
||||
if (!parentSession) {
|
||||
return;
|
||||
}
|
||||
void setCurrentSession(parentSession.id);
|
||||
}, [parentSession, setCurrentSession]);
|
||||
|
||||
const returnToParentButton = parentSession ? (
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleReturnToParentSession}
|
||||
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
|
||||
aria-label="Return to parent session"
|
||||
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
|
||||
>
|
||||
<RiArrowLeftLine className="h-4 w-4" />
|
||||
Parent
|
||||
</ButtonSmall>
|
||||
) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
openNewSessionDraft();
|
||||
@@ -487,9 +528,10 @@ export const ChatContainer: React.FC = () => {
|
||||
if (!hasMessagesEntry) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full bg-background gap-0"
|
||||
className="relative flex flex-col h-full bg-background gap-0"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
{returnToParentButton}
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
||||
<div className="chat-message-column space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -515,6 +557,7 @@ export const ChatContainer: React.FC = () => {
|
||||
className="relative flex flex-col h-full bg-background transform-gpu"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
{returnToParentButton}
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState />
|
||||
@@ -539,6 +582,7 @@ export const ChatContainer: React.FC = () => {
|
||||
className="relative flex flex-col h-full bg-background"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
|
||||
@@ -11,6 +11,46 @@ interface WorkingPlaceholderProps {
|
||||
|
||||
const STATUS_DISPLAY_TIME_MS = 1200;
|
||||
|
||||
const EPOCH_SECONDS_THRESHOLD = 1_000_000_000;
|
||||
const EPOCH_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
|
||||
|
||||
const toRetryTargetTimestamp = (next: number): number => {
|
||||
if (next >= EPOCH_MILLISECONDS_THRESHOLD) {
|
||||
return next;
|
||||
}
|
||||
if (next >= EPOCH_SECONDS_THRESHOLD) {
|
||||
return next * 1000;
|
||||
}
|
||||
return Date.now() + next;
|
||||
};
|
||||
|
||||
const formatRetryCountdown = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainderSeconds = seconds % 60;
|
||||
return remainderSeconds > 0 ? `${minutes}m ${remainderSeconds}s` : `${minutes}m`;
|
||||
}
|
||||
|
||||
if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const remainderMinutes = Math.floor((seconds % 3600) / 60);
|
||||
return remainderMinutes > 0 ? `${hours}h ${remainderMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const remainderHours = Math.floor((seconds % 86400) / 3600);
|
||||
if (remainderHours > 0) {
|
||||
return `${days}d ${remainderHours}h`;
|
||||
}
|
||||
|
||||
return `${days}d`;
|
||||
|
||||
};
|
||||
|
||||
export function WorkingPlaceholder({
|
||||
isWorking,
|
||||
statusText,
|
||||
@@ -26,26 +66,19 @@ export function WorkingPlaceholder({
|
||||
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Countdown state for retry mode
|
||||
const retryNextRef = React.useRef<number | null>(null);
|
||||
const retryStartRef = React.useRef<number | null>(null);
|
||||
const [retryCountdown, setRetryCountdown] = React.useState<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const next = retryInfo?.next;
|
||||
if (!next || next <= 0) {
|
||||
retryNextRef.current = null;
|
||||
retryStartRef.current = null;
|
||||
const rawNext = retryInfo?.next;
|
||||
if (!rawNext || rawNext <= 0) {
|
||||
setRetryCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a fresh countdown when next value or attempt changes
|
||||
retryNextRef.current = next;
|
||||
retryStartRef.current = Date.now();
|
||||
const retryTargetAt = toRetryTargetTimestamp(rawNext);
|
||||
|
||||
const update = () => {
|
||||
const elapsed = Date.now() - (retryStartRef.current ?? Date.now());
|
||||
const remaining = Math.max(0, next - elapsed);
|
||||
const remaining = Math.max(0, retryTargetAt - Date.now());
|
||||
setRetryCountdown(Math.ceil(remaining / 1000));
|
||||
};
|
||||
|
||||
@@ -151,7 +184,9 @@ export function WorkingPlaceholder({
|
||||
// Retry state: show countdown and attempt info
|
||||
if (retryInfo) {
|
||||
const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : '';
|
||||
const countdownLabel = retryCountdown !== null && retryCountdown > 0 ? ` in ${retryCountdown}s` : '';
|
||||
const countdownLabel = retryCountdown !== null && retryCountdown > 0
|
||||
? ` in ${formatRetryCountdown(retryCountdown)}`
|
||||
: '';
|
||||
const retryText = `Retrying${countdownLabel}${attemptLabel}...`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -351,7 +351,11 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-56 max-h-[70vh] overflow-y-auto"
|
||||
style={{ translate: '-30px 0' }}
|
||||
>
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={() => void handleCopyPath()}>
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Copy Path</span>
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
|
||||
@@ -165,12 +166,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return state.messages.get(currentSessionId);
|
||||
});
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const activeProjectLabel = useProjectsStore((state) => {
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
if (!state.activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProject = state.projects.find((project) => project.id === state.activeProjectId);
|
||||
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
|
||||
});
|
||||
const activeProjectLabel = React.useMemo(() => {
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
@@ -182,7 +184,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
|
||||
return pathSegments[pathSegments.length - 1] ?? null;
|
||||
});
|
||||
}, [activeProject]);
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||
@@ -491,6 +493,17 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return worktreeDirectory || sessionDirectory || draftDirectory;
|
||||
}, [draftDirectory, sessionDirectory, worktreeDirectory]);
|
||||
|
||||
const actionDirectory = React.useMemo(() => {
|
||||
return normalize(openDirectory || activeProject?.path || '');
|
||||
}, [activeProject?.path, openDirectory]);
|
||||
|
||||
const activeProjectRef = React.useMemo(() => {
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
return { id: activeProject.id, path: activeProject.path };
|
||||
}, [activeProject]);
|
||||
|
||||
|
||||
const [planTabAvailable, setPlanTabAvailable] = React.useState(false);
|
||||
const showPlanTab = planTabAvailable;
|
||||
@@ -967,6 +980,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProjectRef && actionDirectory && (
|
||||
<ProjectActionsButton
|
||||
projectRef={activeProjectRef}
|
||||
directory={actionDirectory}
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
||||
{tabs.map((tab) => renderTab(tab))}
|
||||
@@ -1505,6 +1526,15 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{activeProjectRef && actionDirectory && (
|
||||
<ProjectActionsButton
|
||||
projectRef={activeProjectRef}
|
||||
directory={actionDirectory}
|
||||
compact
|
||||
allowMobile
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Services Menu (Usage + MCP) */}
|
||||
<DropdownMenu
|
||||
|
||||
@@ -64,6 +64,7 @@ const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
|
||||
|
||||
type NavRailActionButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
icon: React.ReactNode;
|
||||
tooltipLabel: string;
|
||||
@@ -76,6 +77,7 @@ type NavRailActionButtonProps = {
|
||||
|
||||
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
||||
onClick,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
icon,
|
||||
tooltipLabel,
|
||||
@@ -85,12 +87,56 @@ const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
||||
showExpandedContent,
|
||||
actionTextVisible,
|
||||
}) => {
|
||||
const pointerTriggeredRef = React.useRef(false);
|
||||
const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({
|
||||
active: false,
|
||||
pointerId: null,
|
||||
});
|
||||
|
||||
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled || event.button !== 0) {
|
||||
pointerPressRef.current = { active: false, pointerId: null };
|
||||
return;
|
||||
}
|
||||
pointerPressRef.current = { active: true, pointerId: event.pointerId };
|
||||
}, [disabled]);
|
||||
|
||||
const clearPointerPress = React.useCallback(() => {
|
||||
pointerPressRef.current = { active: false, pointerId: null };
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
if (event.button !== 0) return;
|
||||
const pointerPress = pointerPressRef.current;
|
||||
if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
clearPointerPress();
|
||||
pointerTriggeredRef.current = true;
|
||||
onClick();
|
||||
}, [clearPointerPress, disabled, onClick]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (disabled) return;
|
||||
if (pointerTriggeredRef.current) {
|
||||
pointerTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}, [disabled, onClick]);
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={clearPointerPress}
|
||||
onPointerLeave={clearPointerPress}
|
||||
onClick={handleClick}
|
||||
className={buttonClassName}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showExpandedContent && (
|
||||
<span
|
||||
@@ -423,6 +469,19 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||
const isOverlayBlockingNavRailActions = useUIStore((s) => (
|
||||
s.isSettingsDialogOpen
|
||||
|| s.isHelpDialogOpen
|
||||
|| s.isCommandPaletteOpen
|
||||
|| s.isSessionSwitcherOpen
|
||||
|| s.isAboutDialogOpen
|
||||
|| s.isOpenCodeStatusDialogOpen
|
||||
|| s.isSessionCreateDialogOpen
|
||||
|| s.isModelSelectorOpen
|
||||
|| s.isTimelineDialogOpen
|
||||
|| s.isMultiRunLauncherOpen
|
||||
|| s.isImagePreviewOpen
|
||||
));
|
||||
const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
|
||||
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
|
||||
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
||||
@@ -473,6 +532,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
const updateStore = useUpdateStore();
|
||||
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
|
||||
|
||||
const [editingProject, setEditingProject] = React.useState<{
|
||||
id: string;
|
||||
@@ -676,6 +736,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
||||
showExpandedContent ? 'items-stretch' : 'items-center',
|
||||
navRailInteractionBlocked && 'pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
||||
@@ -724,6 +785,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||
<NavRailActionButton
|
||||
onClick={handleAddProject}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Add project"
|
||||
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Add project"
|
||||
@@ -742,6 +804,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
{(updateAvailable || updateDownloaded) && (
|
||||
<NavRailActionButton
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Update available"
|
||||
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Update available"
|
||||
@@ -754,6 +817,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
||||
<NavRailActionButton
|
||||
onClick={() => setAboutDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="About"
|
||||
icon={<RiInformationLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="About OpenChamber"
|
||||
@@ -766,6 +830,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
{!mobile && (
|
||||
<NavRailActionButton
|
||||
onClick={toggleHelpDialog}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Keyboard shortcuts"
|
||||
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Shortcuts"
|
||||
@@ -779,6 +844,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
|
||||
<NavRailActionButton
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Settings"
|
||||
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
||||
tooltipLabel="Settings"
|
||||
@@ -793,6 +859,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
{!mobile && (
|
||||
<NavRailActionButton
|
||||
onClick={toggleNavRail}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
icon={expanded
|
||||
? <RiMenuFoldLine className={navRailActionIconClass} />
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiLoader4Line,
|
||||
RiPlayLine,
|
||||
RiStopLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import {
|
||||
getProjectActionsState,
|
||||
type OpenChamberProjectAction,
|
||||
type ProjectRef,
|
||||
} from '@/lib/openchamberConfig';
|
||||
import {
|
||||
normalizeProjectActionDirectory,
|
||||
PROJECT_ACTIONS_UPDATED_EVENT,
|
||||
PROJECT_ACTION_ICON_MAP,
|
||||
resolveProjectActionDesktopForwardUrl,
|
||||
toProjectActionRunKey,
|
||||
} from '@/lib/projectActions';
|
||||
|
||||
type RunningEntry = {
|
||||
key: string;
|
||||
directory: string;
|
||||
actionId: string;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
status: 'running' | 'stopping';
|
||||
};
|
||||
|
||||
type UrlWatchEntry = {
|
||||
lastSeenChunkId: number | null;
|
||||
openedUrl: boolean;
|
||||
tail: string;
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
interface ProjectActionsButtonProps {
|
||||
projectRef: ProjectRef | null;
|
||||
directory: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
allowMobile?: boolean;
|
||||
}
|
||||
|
||||
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
||||
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
|
||||
|
||||
const stripControlChars = (value: string): string => {
|
||||
let next = '';
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
const isControl = (code >= 0 && code <= 8)
|
||||
|| code === 11
|
||||
|| code === 12
|
||||
|| (code >= 14 && code <= 31)
|
||||
|| code === 127;
|
||||
if (!isControl) {
|
||||
next += value[index];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const normalizeManualOpenUrl = (value: string | undefined): string | null => {
|
||||
const raw = (value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const extractBestUrl = (value: string): string | null => {
|
||||
const cleaned = value.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const matches = cleaned.match(URL_GLOBAL_PATTERN);
|
||||
if (!matches || matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = matches
|
||||
.map((entry) => entry.replace(/[),.;]+$/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portCandidates: Array<{ raw: string; parsed: URL }> = [];
|
||||
for (const candidate of normalized) {
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (parsed.port && parsed.port.length > 0) {
|
||||
portCandidates.push({ raw: candidate, parsed });
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
if (portCandidates.length > 0) {
|
||||
const scoreCandidate = (entry: { raw: string; parsed: URL }): number => {
|
||||
const { parsed } = entry;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
|
||||
const normalizedPath = parsed.pathname || '/';
|
||||
const pathSegments = normalizedPath.split('/').filter(Boolean).length;
|
||||
const hasRootPath = normalizedPath === '/' || normalizedPath === '';
|
||||
const hasQueryOrHash = Boolean(parsed.search || parsed.hash);
|
||||
|
||||
let score = 0;
|
||||
if (isLocalHost) score += 50;
|
||||
if (hasRootPath) score += 30;
|
||||
score -= Math.min(pathSegments * 5, 20);
|
||||
if (hasQueryOrHash) score -= 10;
|
||||
return score;
|
||||
};
|
||||
|
||||
portCandidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
|
||||
return portCandidates[0]?.parsed.origin ?? portCandidates[0]?.raw ?? null;
|
||||
}
|
||||
|
||||
return normalized[0] ?? null;
|
||||
};
|
||||
|
||||
export const ProjectActionsButton = ({
|
||||
projectRef,
|
||||
directory,
|
||||
className,
|
||||
compact = false,
|
||||
allowMobile = false,
|
||||
}: ProjectActionsButtonProps) => {
|
||||
const { terminal, runtime } = useRuntimeAPIs();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
|
||||
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
|
||||
|
||||
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||
|
||||
const terminalSessions = useTerminalStore((state) => state.sessions);
|
||||
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
|
||||
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
|
||||
const setActiveTab = useTerminalStore((state) => state.setActiveTab);
|
||||
const setConnecting = useTerminalStore((state) => state.setConnecting);
|
||||
const setTabSessionId = useTerminalStore((state) => state.setTabSessionId);
|
||||
|
||||
const [actions, setActions] = React.useState<OpenChamberProjectAction[]>([]);
|
||||
const [selectedActionId, setSelectedActionId] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [runningByKey, setRunningByKey] = React.useState<Record<string, RunningEntry>>({});
|
||||
const tabByKeyRef = React.useRef<Record<string, string>>({});
|
||||
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
|
||||
|
||||
const projectId = projectRef?.id ?? null;
|
||||
const projectPath = projectRef?.path ?? '';
|
||||
|
||||
const stableProjectRef = React.useMemo(() => {
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
return { id: projectId, path: projectPath };
|
||||
}, [projectId, projectPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellApp) {
|
||||
return;
|
||||
}
|
||||
void loadDesktopSsh().catch(() => undefined);
|
||||
}, [isDesktopShellApp, loadDesktopSsh]);
|
||||
|
||||
const openExternal = React.useCallback(async (url: string) => {
|
||||
try {
|
||||
const tauri = (window as unknown as {
|
||||
__TAURI__?: {
|
||||
shell?: {
|
||||
open?: (target: string) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}).__TAURI__;
|
||||
if (tauri?.shell?.open) {
|
||||
await tauri.shell.open(url);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const loadActions = React.useCallback(async () => {
|
||||
if (!stableProjectRef) {
|
||||
setActions([]);
|
||||
setSelectedActionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const state = await getProjectActionsState(stableProjectRef);
|
||||
const filtered = state.actions;
|
||||
setActions(filtered);
|
||||
setSelectedActionId(filtered[0]?.id ?? null);
|
||||
} catch {
|
||||
setActions([]);
|
||||
setSelectedActionId(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [stableProjectRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadActions();
|
||||
}, [loadActions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
if (detail?.projectId && detail.projectId !== projectId) {
|
||||
return;
|
||||
}
|
||||
void loadActions();
|
||||
};
|
||||
|
||||
window.addEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
|
||||
};
|
||||
}, [loadActions, projectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedActionId) {
|
||||
return;
|
||||
}
|
||||
if (!actions.some((entry) => entry.id === selectedActionId)) {
|
||||
setSelectedActionId(actions[0]?.id ?? null);
|
||||
}
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRunningByKey((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, RunningEntry> = {};
|
||||
|
||||
for (const [key, entry] of Object.entries(prev)) {
|
||||
const directoryState = terminalSessions.get(entry.directory);
|
||||
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
|
||||
if (!tab || tab.terminalSessionId !== entry.sessionId) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = entry;
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [terminalSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const [runKey, entry] of Object.entries(runningByKey)) {
|
||||
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '' };
|
||||
urlWatchByRunKeyRef.current[runKey] = watch;
|
||||
const action = actions.find((item) => item.id === entry.actionId);
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const directoryState = terminalSessions.get(entry.directory);
|
||||
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
|
||||
if (!tab || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChunks = tab.bufferChunks.filter((chunk) => {
|
||||
if (watch.lastSeenChunkId === null) {
|
||||
return true;
|
||||
}
|
||||
return chunk.id > watch.lastSeenChunkId;
|
||||
});
|
||||
|
||||
if (nextChunks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const combined = nextChunks.map((chunk) => chunk.data).join('');
|
||||
const textForScan = `${watch.tail}${combined}`;
|
||||
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
|
||||
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
|
||||
|
||||
watch.lastSeenChunkId = lastChunkId;
|
||||
watch.tail = textForScan.slice(-512);
|
||||
|
||||
if (maybeUrl) {
|
||||
watch.openedUrl = true;
|
||||
void openExternal(maybeUrl);
|
||||
toast.success('Opened URL from action output');
|
||||
}
|
||||
urlWatchByRunKeyRef.current[runKey] = watch;
|
||||
}
|
||||
|
||||
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
|
||||
if (!runningByKey[runKey]) {
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
}
|
||||
}
|
||||
|
||||
}, [actions, openExternal, runningByKey, terminalSessions]);
|
||||
|
||||
const normalizedDirectory = React.useMemo(() => {
|
||||
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
|
||||
}, [directory, stableProjectRef?.path]);
|
||||
|
||||
const selectedAction = React.useMemo(() => {
|
||||
if (!selectedActionId) {
|
||||
return actions[0] ?? null;
|
||||
}
|
||||
return actions.find((entry) => entry.id === selectedActionId) ?? actions[0] ?? null;
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
|
||||
if (!normalizedDirectory) {
|
||||
throw new Error('No active directory');
|
||||
}
|
||||
|
||||
const key = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
ensureDirectory(normalizedDirectory);
|
||||
|
||||
const currentStore = useTerminalStore.getState();
|
||||
const existingDirectoryState = currentStore.getDirectoryState(normalizedDirectory);
|
||||
|
||||
let tabId = tabByKeyRef.current[key] || null;
|
||||
const hasTab = tabId
|
||||
? Boolean(existingDirectoryState?.tabs.some((entry) => entry.id === tabId))
|
||||
: false;
|
||||
|
||||
if (!tabId || !hasTab) {
|
||||
tabId = currentStore.createTab(normalizedDirectory);
|
||||
tabByKeyRef.current[key] = tabId;
|
||||
}
|
||||
|
||||
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
|
||||
setActiveTab(normalizedDirectory, tabId);
|
||||
|
||||
setBottomTerminalOpen(true);
|
||||
setActiveMainTab('terminal');
|
||||
|
||||
const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory);
|
||||
const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId);
|
||||
return {
|
||||
key,
|
||||
tabId,
|
||||
sessionId: tab?.terminalSessionId ?? null,
|
||||
};
|
||||
}, [
|
||||
ensureDirectory,
|
||||
normalizedDirectory,
|
||||
setActiveMainTab,
|
||||
setActiveTab,
|
||||
setBottomTerminalOpen,
|
||||
setTabLabel,
|
||||
]);
|
||||
|
||||
const runAction = React.useCallback(async (action: OpenChamberProjectAction) => {
|
||||
if (runtime.isVSCode || (!allowMobile && isMobile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedDirectory) {
|
||||
toast.error('No active directory for action');
|
||||
return;
|
||||
}
|
||||
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const existingRun = runningByKey[runKey];
|
||||
if (existingRun && existingRun.status === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, tabId, sessionId } = await getOrCreateActionTab(action);
|
||||
let activeSessionId = sessionId;
|
||||
let createdSession = false;
|
||||
|
||||
if (!activeSessionId) {
|
||||
setConnecting(normalizedDirectory, tabId, true);
|
||||
try {
|
||||
const created = await terminal.createSession({ cwd: normalizedDirectory });
|
||||
activeSessionId = created.sessionId;
|
||||
createdSession = true;
|
||||
setTabSessionId(normalizedDirectory, tabId, activeSessionId);
|
||||
} finally {
|
||||
setConnecting(normalizedDirectory, tabId, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
throw new Error('Failed to create terminal session');
|
||||
}
|
||||
|
||||
if (createdSession) {
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
key,
|
||||
directory: normalizedDirectory,
|
||||
actionId: action.id,
|
||||
tabId,
|
||||
sessionId: activeSessionId,
|
||||
status: 'running',
|
||||
},
|
||||
}));
|
||||
|
||||
const hasCustomOpenUrl = action.autoOpenUrl === true && (action.openUrl || '').trim().length > 0;
|
||||
const hasDesktopForwardSelection = action.autoOpenUrl === true
|
||||
&& isDesktopShellApp
|
||||
&& (action.desktopOpenSshForward || '').trim().length > 0;
|
||||
const manualOpenUrl = action.autoOpenUrl ? normalizeManualOpenUrl(action.openUrl) : null;
|
||||
const desktopForwardUrl = action.autoOpenUrl && isDesktopShellApp
|
||||
? resolveProjectActionDesktopForwardUrl(action.desktopOpenSshForward, desktopSshInstances)
|
||||
: null;
|
||||
|
||||
if (desktopForwardUrl) {
|
||||
void openExternal(desktopForwardUrl);
|
||||
toast.success('Opened forwarded URL');
|
||||
} else if (manualOpenUrl) {
|
||||
void openExternal(manualOpenUrl);
|
||||
toast.success('Opened action URL');
|
||||
} else if (hasCustomOpenUrl) {
|
||||
toast.error('Invalid custom URL format');
|
||||
} else if (hasDesktopForwardSelection) {
|
||||
toast.error('Selected desktop SSH forward is unavailable');
|
||||
}
|
||||
|
||||
urlWatchByRunKeyRef.current[key] = {
|
||||
lastSeenChunkId: null,
|
||||
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
|
||||
tail: '',
|
||||
};
|
||||
|
||||
const normalizedCommand = stripControlChars(action.command.trim().replace(/\r\n|\r/g, '\n'));
|
||||
await terminal.sendInput(activeSessionId, `${normalizedCommand}\n\u0004`);
|
||||
} catch (error) {
|
||||
setRunningByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runKey];
|
||||
return next;
|
||||
});
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to run action');
|
||||
}
|
||||
}, [
|
||||
desktopSshInstances,
|
||||
getOrCreateActionTab,
|
||||
allowMobile,
|
||||
isMobile,
|
||||
isDesktopShellApp,
|
||||
normalizedDirectory,
|
||||
openExternal,
|
||||
runningByKey,
|
||||
runtime.isVSCode,
|
||||
setConnecting,
|
||||
setTabSessionId,
|
||||
terminal,
|
||||
]);
|
||||
|
||||
const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => {
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const activeRun = runningByKey[runKey];
|
||||
if (!activeRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => ({
|
||||
...prev,
|
||||
[runKey]: {
|
||||
...activeRun,
|
||||
status: 'stopping',
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await terminal.sendInput(activeRun.sessionId, '\x03');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs
|
||||
.find((entry) => entry.id === activeRun.tabId);
|
||||
|
||||
const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId;
|
||||
|
||||
if (sessionStillSame) {
|
||||
if (typeof terminal.forceKill === 'function') {
|
||||
try {
|
||||
await terminal.forceKill({ sessionId: activeRun.sessionId });
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await terminal.close(activeRun.sessionId);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
setTabSessionId(activeRun.directory, activeRun.tabId, null);
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runKey];
|
||||
return next;
|
||||
});
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
}, [normalizedDirectory, runningByKey, setTabSessionId, terminal]);
|
||||
|
||||
const handlePrimaryClick = React.useCallback(() => {
|
||||
if (!selectedAction) {
|
||||
return;
|
||||
}
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, selectedAction.id);
|
||||
const runningEntry = runningByKey[runKey];
|
||||
if (runningEntry?.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
if (runningEntry) {
|
||||
void stopAction(selectedAction);
|
||||
return;
|
||||
}
|
||||
void runAction(selectedAction);
|
||||
}, [normalizedDirectory, runAction, runningByKey, selectedAction, stopAction]);
|
||||
|
||||
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
|
||||
setSelectedActionId(action.id);
|
||||
|
||||
if (!toggleStopIfRunning) {
|
||||
void runAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const runningEntry = runningByKey[runKey];
|
||||
if (runningEntry?.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
if (runningEntry) {
|
||||
void stopAction(action);
|
||||
return;
|
||||
}
|
||||
void runAction(action);
|
||||
}, [normalizedDirectory, runAction, runningByKey, stopAction]);
|
||||
|
||||
const openProjectActionsSettings = React.useCallback(() => {
|
||||
if (!stableProjectRef?.id) {
|
||||
return;
|
||||
}
|
||||
setSettingsProjectsSelectedId(stableProjectRef.id);
|
||||
setSettingsPage('projects');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]);
|
||||
|
||||
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
aria-label="Add action"
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-7 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] pl-1.5 pr-2.5 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="header-open-label">Add action</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSelected = selectedAction ?? actions[0] ?? null;
|
||||
if (!resolvedSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
|
||||
const selectedRunning = runningByKey[selectedRunKey];
|
||||
const isStoppingSelected = selectedRunning?.status === 'stopping';
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
>
|
||||
{isStoppingSelected
|
||||
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
|
||||
: selectedRunning
|
||||
? <RiStopLine className="h-5 w-5 text-[var(--status-warning)]" />
|
||||
: <SelectedIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Add new action</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = runningByKey[runKey];
|
||||
const isRunning = Boolean(runState);
|
||||
const isStopping = runState?.status === 'stopping';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleSelectAction(entry, true);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
||||
{isStopping
|
||||
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: isRunning
|
||||
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
||||
: null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex items-center self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
|
||||
compact ? 'h-9' : 'h-7',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrimaryClick}
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'inline-flex h-full items-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
|
||||
compact ? 'w-9 justify-center px-0' : 'gap-2 pl-2 pr-3',
|
||||
'transition-colors disabled:opacity-50'
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
>
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{isStoppingSelected
|
||||
? <RiLoader4Line className="h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: selectedRunning
|
||||
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
|
||||
: <SelectedIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
{!compact ? <span className="header-open-label">{resolvedSelected.name}</span> : null}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
|
||||
'border-l border-[var(--interactive-border)] text-muted-foreground',
|
||||
'hover:bg-interactive-hover hover:text-foreground transition-colors'
|
||||
)}
|
||||
aria-label="Choose project action"
|
||||
>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" alignOffset={8} className="w-52 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Add new action</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = runningByKey[runKey];
|
||||
const isRunning = Boolean(runState);
|
||||
const isStopping = runState?.status === 'stopping';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleSelectAction(entry);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
||||
{isStopping
|
||||
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: isRunning
|
||||
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
||||
: null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,434 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiDeleteBinLine,
|
||||
RiInformationLine,
|
||||
RiPlayLine,
|
||||
} from '@remixicon/react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import {
|
||||
getProjectActionsState,
|
||||
saveProjectActionsState,
|
||||
type OpenChamberProjectAction,
|
||||
type ProjectRef,
|
||||
} from '@/lib/openchamberConfig';
|
||||
import {
|
||||
buildProjectActionDesktopForwardOptions,
|
||||
PROJECT_ACTION_ICON_MAP,
|
||||
PROJECT_ACTION_ICONS,
|
||||
PROJECT_ACTIONS_UPDATED_EVENT,
|
||||
} from '@/lib/projectActions';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type EditableProjectAction = OpenChamberProjectAction;
|
||||
|
||||
const createActionId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `action_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
};
|
||||
|
||||
const createEmptyAction = (): EditableProjectAction => ({
|
||||
id: createActionId(),
|
||||
name: '',
|
||||
command: '',
|
||||
icon: 'play',
|
||||
});
|
||||
|
||||
interface ProjectActionsSectionProps {
|
||||
projectRef: ProjectRef;
|
||||
}
|
||||
|
||||
export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ projectRef }) => {
|
||||
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
|
||||
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
|
||||
|
||||
const [actions, setActions] = React.useState<EditableProjectAction[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [initialSnapshot, setInitialSnapshot] = React.useState<string | null>(null);
|
||||
const [expandedActions, setExpandedActions] = React.useState<Record<string, boolean>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellApp) {
|
||||
return;
|
||||
}
|
||||
void loadDesktopSsh().catch(() => undefined);
|
||||
}, [isDesktopShellApp, loadDesktopSsh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const state = await getProjectActionsState(projectRef);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setActions(state.actions);
|
||||
setInitialSnapshot(JSON.stringify({ actions: state.actions }));
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setActions([]);
|
||||
setInitialSnapshot(JSON.stringify({ actions: [] }));
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectRef]);
|
||||
|
||||
const desktopForwardOptions = React.useMemo(() => {
|
||||
if (!isDesktopShellApp) {
|
||||
return [];
|
||||
}
|
||||
return buildProjectActionDesktopForwardOptions(desktopSshInstances);
|
||||
}, [desktopSshInstances, isDesktopShellApp]);
|
||||
|
||||
const validationError = React.useMemo(() => {
|
||||
const hasIncomplete = actions.some((entry) => {
|
||||
return entry.name.trim().length === 0 || entry.command.trim().length === 0;
|
||||
});
|
||||
if (hasIncomplete) {
|
||||
return 'Fill action name and command before saving.';
|
||||
}
|
||||
return null;
|
||||
}, [actions]);
|
||||
|
||||
const hasChanges = React.useMemo(() => {
|
||||
if (initialSnapshot === null) {
|
||||
return false;
|
||||
}
|
||||
return initialSnapshot !== JSON.stringify({ actions });
|
||||
}, [actions, initialSnapshot]);
|
||||
|
||||
const handleAddAction = React.useCallback(() => {
|
||||
const nextAction = createEmptyAction();
|
||||
setActions((prev) => [...prev, nextAction]);
|
||||
setExpandedActions((prev) => ({
|
||||
...prev,
|
||||
[nextAction.id]: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveAction = React.useCallback((id: string) => {
|
||||
setActions((prev) => prev.filter((entry) => entry.id !== id));
|
||||
setExpandedActions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateAction = React.useCallback((id: string, updater: (current: EditableProjectAction) => EditableProjectAction) => {
|
||||
setActions((prev) => prev.map((entry) => (entry.id === id ? updater(entry) : entry)));
|
||||
}, []);
|
||||
|
||||
const handleSave = React.useCallback(async () => {
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const ok = await saveProjectActionsState(projectRef, {
|
||||
actions,
|
||||
primaryActionId: null,
|
||||
});
|
||||
if (!ok) {
|
||||
toast.error('Failed to save actions');
|
||||
return;
|
||||
}
|
||||
setInitialSnapshot(JSON.stringify({ actions }));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, {
|
||||
detail: { projectId: projectRef.id },
|
||||
}));
|
||||
}
|
||||
toast.success('Project actions saved');
|
||||
} catch {
|
||||
toast.error('Failed to save actions');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [actions, projectRef, validationError]);
|
||||
|
||||
const canSave = !isSaving && !isLoading && hasChanges && !validationError;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
|
||||
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
|
||||
</div>
|
||||
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add action
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
|
||||
<section className="pb-2 pt-0 space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
) : actions.length === 0 ? (
|
||||
<div className="py-2">
|
||||
<p className="typography-meta text-muted-foreground">No actions configured yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0 max-w-[30rem]">
|
||||
{actions.map((action) => {
|
||||
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const isOpen = expandedActions[action.id] ?? false;
|
||||
const title = action.name.trim() || 'Untitled action';
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={action.id}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setExpandedActions((prev) => ({
|
||||
...prev,
|
||||
[action.id]: open,
|
||||
}));
|
||||
}}
|
||||
className={cn(
|
||||
'py-1.5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CollapsibleTrigger className="group flex-1 justify-start gap-2 rounded-md px-0 pr-1 py-1 hover:bg-[var(--interactive-hover)] focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]">
|
||||
{isOpen ? (
|
||||
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<SelectedIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground truncate">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-7 w-7 px-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
onClick={() => handleRemoveAction(action.id)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="pt-1.5">
|
||||
<div className="space-y-2 pb-6 pl-3 pr-3">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
|
||||
aria-label="Select icon"
|
||||
>
|
||||
<SelectedIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 p-2">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{PROJECT_ACTION_ICONS.map((entry) => {
|
||||
const Icon = entry.Icon;
|
||||
const selected = (action.icon || 'play') === entry.key;
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
onClick={() => updateAction(action.id, (current) => ({ ...current, icon: entry.key }))}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-foreground hover:bg-[var(--interactive-hover)]',
|
||||
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/10 text-[var(--primary-base)]'
|
||||
)}
|
||||
aria-label={`Icon ${entry.label}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Action name"
|
||||
className="h-7 max-w-[14rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">Command</p>
|
||||
<Textarea
|
||||
value={action.command}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({ ...current, command: event.target.value }))}
|
||||
placeholder="e.g. bun run lint"
|
||||
className="min-h-[88px] max-w-[30rem] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="typography-ui-label text-foreground">Auto-open URL</span>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={action.autoOpenUrl === true}
|
||||
onClick={() => updateAction(action.id, (current) => ({
|
||||
...current,
|
||||
...(current.autoOpenUrl === true ? { autoOpenUrl: undefined } : { autoOpenUrl: true }),
|
||||
}))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
updateAction(action.id, (current) => ({
|
||||
...current,
|
||||
...(current.autoOpenUrl === true ? { autoOpenUrl: undefined } : { autoOpenUrl: true }),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={action.autoOpenUrl === true}
|
||||
onChange={(checked) => updateAction(action.id, (current) => ({
|
||||
...current,
|
||||
...(checked ? { autoOpenUrl: true } : { autoOpenUrl: undefined }),
|
||||
}))}
|
||||
ariaLabel={`Auto-open URL for ${title}`}
|
||||
/>
|
||||
<span className="typography-ui-label font-normal text-foreground/80">Open URL from output or custom URL below</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action.autoOpenUrl === true ? (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={action.openUrl || ''}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({
|
||||
...current,
|
||||
openUrl: event.target.value,
|
||||
}))}
|
||||
placeholder="Override URL (optional)"
|
||||
className="h-7 w-full max-w-[24rem]"
|
||||
/>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
If this field is filled, custom URL is used. If empty, app opens best URL from output.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{isDesktopShellApp ? (
|
||||
<div className="mt-2">
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">Desktop SSH forward</p>
|
||||
{desktopForwardOptions.length > 0 ? (
|
||||
<Select
|
||||
value={
|
||||
action.desktopOpenSshForward && desktopForwardOptions.some((entry) => entry.id === action.desktopOpenSshForward)
|
||||
? action.desktopOpenSshForward
|
||||
: '__none__'
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
updateAction(action.id, (current) => ({
|
||||
...current,
|
||||
...(value === '__none__' ? { desktopOpenSshForward: undefined } : { desktopOpenSshForward: value }),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full max-w-[30rem]">
|
||||
<SelectValue placeholder="Use output/manual URL" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Use output/manual URL</SelectItem>
|
||||
{desktopForwardOptions.map((entry) => (
|
||||
<SelectItem key={entry.id} value={entry.id}>{entry.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">No enabled local SSH forwards available.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-3">
|
||||
{validationError ? (
|
||||
<p className="typography-meta mb-2 text-[var(--status-warning)]">{validationError}</p>
|
||||
) : null}
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Actions'}
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { RiCloseLine } from '@remixicon/react';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
|
||||
export const ProjectsPage: React.FC = () => {
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
@@ -45,6 +46,13 @@ export const ProjectsPage: React.FC = () => {
|
||||
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedProjectRef = React.useMemo(() => {
|
||||
if (!selectedProject) {
|
||||
return null;
|
||||
}
|
||||
return { id: selectedProject.id, path: selectedProject.path };
|
||||
}, [selectedProject]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setName('');
|
||||
@@ -149,7 +157,6 @@ export const ProjectsPage: React.FC = () => {
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
|
||||
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
|
||||
@@ -370,6 +377,13 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Worktree Group */}
|
||||
<div className="mb-8">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{selectedProjectRef && <ProjectActionsSection projectRef={selectedProjectRef} />}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Worktree Group */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
@@ -378,7 +392,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<WorktreeSectionContent projectRef={{ id: selectedProject.id, path: selectedProject.path }} />
|
||||
{selectedProjectRef && <WorktreeSectionContent projectRef={selectedProjectRef} />}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,6 +55,16 @@ type TerminalController = {
|
||||
fit: () => void;
|
||||
};
|
||||
|
||||
type TerminalWithViewport = {
|
||||
scrollToBottom?: () => void;
|
||||
getViewportY?: () => number;
|
||||
hasSelection?: () => boolean;
|
||||
};
|
||||
|
||||
type FitAddonWithObserveResize = FitAddon & {
|
||||
observeResize?: () => void;
|
||||
};
|
||||
|
||||
interface TerminalViewportProps {
|
||||
sessionKey: string;
|
||||
chunks: TerminalChunk[];
|
||||
@@ -97,6 +107,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
const writeScheduledRef = React.useRef<number | null>(null);
|
||||
const isWritingRef = React.useRef(false);
|
||||
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
|
||||
const followOutputRef = React.useRef(true);
|
||||
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
||||
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
||||
@@ -313,7 +324,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!useHiddenInputOverlay || !container) {
|
||||
if (!useHiddenInputOverlay || !container || enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,7 +349,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return () => {
|
||||
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
||||
};
|
||||
}, [useHiddenInputOverlay, focusHiddenInput]);
|
||||
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput]);
|
||||
|
||||
const getTerminalSelectionText = React.useCallback((): string => {
|
||||
const terminal = terminalRef.current as unknown as {
|
||||
@@ -834,10 +845,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
scrollByPixels(deltaPixels);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
const wasTap = !state.didMove;
|
||||
|
||||
|
||||
state.lastY = null;
|
||||
state.lastTime = null;
|
||||
|
||||
@@ -918,6 +928,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
let localResizeObserver: ResizeObserver | null = null;
|
||||
let localTextareaObserver: MutationObserver | null = null;
|
||||
let localDisposables: Array<{ dispose: () => void }> = [];
|
||||
let restorePatchedScrollToBottom: (() => void) | null = null;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
@@ -956,9 +967,23 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
|
||||
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false);
|
||||
|
||||
const terminal = new GhosttyTerminal(options);
|
||||
followOutputRef.current = true;
|
||||
|
||||
const terminalWithViewport = terminal as unknown as TerminalWithViewport;
|
||||
if (typeof terminalWithViewport.scrollToBottom === 'function') {
|
||||
const originalScrollToBottom = terminalWithViewport.scrollToBottom.bind(terminalWithViewport);
|
||||
terminalWithViewport.scrollToBottom = () => {
|
||||
if (followOutputRef.current) {
|
||||
originalScrollToBottom();
|
||||
}
|
||||
};
|
||||
restorePatchedScrollToBottom = () => {
|
||||
terminalWithViewport.scrollToBottom = originalScrollToBottom;
|
||||
};
|
||||
}
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
|
||||
@@ -999,11 +1024,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
|
||||
fitTerminal();
|
||||
const fitAddonWithResize = fitAddon as FitAddonWithObserveResize;
|
||||
if (typeof fitAddonWithResize.observeResize === 'function') {
|
||||
fitAddonWithResize.observeResize();
|
||||
}
|
||||
setupTouchScroll();
|
||||
localDisposables = [
|
||||
terminal.onData((data: string) => {
|
||||
inputHandlerRef.current(data);
|
||||
}),
|
||||
terminal.onScroll((viewportY: number) => {
|
||||
if (typeof viewportY === 'number' && Number.isFinite(viewportY)) {
|
||||
const hasSelection = typeof terminal.hasSelection === 'function' && terminal.hasSelection();
|
||||
followOutputRef.current = !hasSelection && viewportY <= 0.5;
|
||||
}
|
||||
}),
|
||||
terminal.onSelectionChange(() => {
|
||||
const hasSelection = typeof terminal.hasSelection === 'function' && terminal.hasSelection();
|
||||
if (hasSelection) {
|
||||
followOutputRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportY = typeof terminal.getViewportY === 'function' ? terminal.getViewportY() : 0;
|
||||
followOutputRef.current = viewportY <= 0.5;
|
||||
}),
|
||||
];
|
||||
|
||||
localResizeObserver = new ResizeObserver(() => {
|
||||
@@ -1035,6 +1080,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
|
||||
localDisposables.forEach((disposable) => disposable.dispose());
|
||||
restorePatchedScrollToBottom?.();
|
||||
restorePatchedScrollToBottom = null;
|
||||
if (localTerminalTextarea) {
|
||||
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
|
||||
localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur);
|
||||
@@ -1421,25 +1468,34 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
||||
style={{ backgroundColor: theme.background }}
|
||||
onTouchStart={(event) => {
|
||||
if (useHiddenInputOverlay) {
|
||||
if (!useHiddenInputOverlay || enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
if (!hasCopyableSelectionInViewport()) {
|
||||
const touch = event.touches?.[0];
|
||||
focusHiddenInput(touch?.clientX, touch?.clientY);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (useHiddenInputOverlay) {
|
||||
if (enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
if (hasCopyableSelectionInViewport()) {
|
||||
return;
|
||||
}
|
||||
focusHiddenInput(event.clientX, event.clientY);
|
||||
} else {
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!enableTouchScroll) {
|
||||
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
|
||||
void copySelectionToClipboard();
|
||||
}
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
if (!enableTouchScroll) {
|
||||
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
|
||||
void copySelectionToClipboard();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -32,6 +32,7 @@ function DropdownMenuTrigger({
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
@@ -42,6 +43,7 @@ function DropdownMenuContent({
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevated)',
|
||||
color: 'var(--surface-elevated-foreground)',
|
||||
...style,
|
||||
}}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-none",
|
||||
|
||||
@@ -19,9 +19,22 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-md"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={descriptionId}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
||||
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useFontPreferences } from '@/hooks/useFontPreferences';
|
||||
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions';
|
||||
import { convertThemeToXterm } from '@/lib/terminalTheme';
|
||||
import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -86,9 +85,11 @@ export const TerminalView: React.FC = () => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const { monoFont } = useFontPreferences();
|
||||
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
||||
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
|
||||
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
// Tabs are supported for web + desktop runtimes (not VSCode).
|
||||
const enableTabs = !isMobile && runtime.platform !== 'vscode';
|
||||
// Tabs are supported for web + desktop runtimes, including mobile (not VSCode).
|
||||
const enableTabs = runtime.platform !== 'vscode';
|
||||
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
||||
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
||||
|
||||
@@ -137,6 +138,7 @@ export const TerminalView: React.FC = () => {
|
||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
|
||||
const keyboardAvoidTargetId = React.useId();
|
||||
|
||||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
@@ -151,6 +153,13 @@ export const TerminalView: React.FC = () => {
|
||||
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const rehydratedSnapshotTakenRef = React.useRef(false);
|
||||
|
||||
const focusTerminalWhenWindowActive = React.useCallback(() => {
|
||||
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
terminalControllerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminalHydrated) {
|
||||
return;
|
||||
@@ -263,7 +272,7 @@ export const TerminalView: React.FC = () => {
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
terminalControllerRef.current?.focus();
|
||||
focusTerminalWhenWindowActive();
|
||||
|
||||
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
||||
// until the first output arrives. Nudge with a newline once.
|
||||
@@ -292,6 +301,10 @@ export const TerminalView: React.FC = () => {
|
||||
const exitCode =
|
||||
typeof event.exitCode === 'number' ? event.exitCode : null;
|
||||
const signal = typeof event.signal === 'number' ? event.signal : null;
|
||||
const currentTab = useTerminalStore.getState()
|
||||
.getDirectoryState(directory)
|
||||
?.tabs.find((t) => t.id === tabId);
|
||||
const isActionTab = Boolean(currentTab?.label?.startsWith('Action:'));
|
||||
appendToBuffer(
|
||||
directory,
|
||||
tabId,
|
||||
@@ -301,7 +314,7 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
setTabSessionId(directory, tabId, null);
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError('Terminal session ended');
|
||||
setConnectionError(isActionTab ? null : 'Terminal session ended');
|
||||
setIsFatalError(false);
|
||||
disconnectStream();
|
||||
break;
|
||||
@@ -335,7 +348,7 @@ export const TerminalView: React.FC = () => {
|
||||
activeTerminalIdRef.current = null;
|
||||
};
|
||||
},
|
||||
[appendToBuffer, disconnectStream, setConnecting, setTabSessionId, terminal]
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -375,6 +388,8 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||
let terminalId = tab?.terminalSessionId ?? null;
|
||||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||||
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
|
||||
|
||||
const shouldNudgeExisting =
|
||||
Boolean(terminalId) &&
|
||||
@@ -386,6 +401,11 @@ export const TerminalView: React.FC = () => {
|
||||
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||||
|
||||
if (!terminalId) {
|
||||
if (isActionTab && hasBufferedOutput) {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setConnecting(directory, tabId, true);
|
||||
@@ -473,27 +493,27 @@ export const TerminalView: React.FC = () => {
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
terminalControllerRef.current?.focus();
|
||||
focusTerminalWhenWindowActive();
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
terminalControllerRef.current?.focus();
|
||||
focusTerminalWhenWindowActive();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [activeTabId, isTerminalVisible]);
|
||||
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible]);
|
||||
|
||||
const handleRestart = React.useCallback(async () => {
|
||||
if (!effectiveDirectory) return;
|
||||
if (isRestarting) return;
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory);
|
||||
const tabId = isMobile
|
||||
? (state?.tabs[0]?.id ?? null)
|
||||
: (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null);
|
||||
const tabId = enableTabs
|
||||
? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null)
|
||||
: (state?.tabs[0]?.id ?? null);
|
||||
if (!tabId) return;
|
||||
|
||||
setIsRestarting(true);
|
||||
@@ -510,7 +530,7 @@ export const TerminalView: React.FC = () => {
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, isMobile, isRestarting]);
|
||||
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting]);
|
||||
|
||||
const handleHardRestart = React.useCallback(async () => {
|
||||
// Keep semantics: “close tab -> new clean tab”.
|
||||
@@ -735,7 +755,29 @@ export const TerminalView: React.FC = () => {
|
||||
return `${directoryPart}::${tabPart}::${terminalPart}`;
|
||||
}, [effectiveDirectory, activeTabId, terminalSessionId]);
|
||||
|
||||
const viewportSessionKey = terminalSessionId ?? terminalSessionKey;
|
||||
const viewportSessionKey = React.useMemo(() => {
|
||||
const base = terminalSessionId ?? terminalSessionKey;
|
||||
return `${base}::layout-${viewportLayoutVersion}`;
|
||||
}, [terminalSessionId, terminalSessionKey, viewportLayoutVersion]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile || !isBottomTerminalOpen || !isTerminalVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setViewportLayoutVersion((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setViewportLayoutVersion((value) => value + 1);
|
||||
}, 140);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTerminalVisible) {
|
||||
@@ -751,7 +793,7 @@ export const TerminalView: React.FC = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
fitOnce();
|
||||
controller.focus();
|
||||
focusTerminalWhenWindowActive();
|
||||
});
|
||||
const timeoutIds = [220, 400].map((delay) => window.setTimeout(fitOnce, delay));
|
||||
return () => {
|
||||
@@ -760,7 +802,35 @@ export const TerminalView: React.FC = () => {
|
||||
};
|
||||
}
|
||||
fitOnce();
|
||||
}, [isTerminalVisible, terminalSessionKey, terminalSessionId]);
|
||||
}, [focusTerminalWhenWindowActive, isTerminalVisible, terminalSessionKey, terminalSessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile || !isTerminalVisible || !isBottomTerminalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = terminalControllerRef.current;
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fitOnce = () => {
|
||||
controller.fit();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
fitOnce();
|
||||
});
|
||||
const timeoutIds = [0, 80, 180, 320].map((delay) => window.setTimeout(fitOnce, delay));
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
timeoutIds.forEach((id) => window.clearTimeout(id));
|
||||
};
|
||||
}
|
||||
|
||||
fitOnce();
|
||||
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
|
||||
|
||||
if (!hasActiveContext) {
|
||||
return (
|
||||
@@ -901,18 +971,19 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
||||
<div className="sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] px-5 py-2 text-xs">
|
||||
<div className={cn('sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'px-4 py-1.5' : 'px-5 py-2')}>
|
||||
{enableTabs && directoryTerminalState ? (
|
||||
<div className="mt-2 pl-1 pr-1 flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto pb-1">
|
||||
<div className="flex w-max items-center gap-1 pr-1">
|
||||
<div className={cn('pl-1 pr-1 flex items-center gap-2', isMobile ? 'mt-1' : 'mt-2')}>
|
||||
<div className={cn('min-w-0 flex-1 overflow-x-auto', isMobile ? 'pb-0.5' : 'pb-1')}>
|
||||
<div className={cn('flex w-max items-center pr-1', isMobile ? 'gap-1' : 'gap-1')}>
|
||||
{directoryTerminalState.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
|
||||
'group flex items-center rounded-md border whitespace-nowrap',
|
||||
isMobile ? 'h-8 gap-0.5 pl-2 pr-1.5 text-sm leading-none' : 'gap-1 pl-2 pr-1 py-1 text-xs',
|
||||
isActive
|
||||
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
||||
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
||||
@@ -921,7 +992,10 @@ export const TerminalView: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectTab(tab.id)}
|
||||
className="max-w-[10rem] truncate text-left"
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
isMobile ? '!min-h-0 !min-w-0 max-w-[9.5rem]' : 'max-w-[10rem]'
|
||||
)}
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -929,8 +1003,9 @@ export const TerminalView: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||
!isActive && 'opacity-0 group-hover:opacity-100'
|
||||
'flex items-center justify-center rounded-sm text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||
isMobile ? '!min-h-0 !min-w-0 h-3.5 w-3.5 p-0 leading-none' : 'h-4 w-4 p-0 leading-none',
|
||||
!isMobile && !isActive && 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -938,7 +1013,7 @@ export const TerminalView: React.FC = () => {
|
||||
}}
|
||||
title="Close tab"
|
||||
>
|
||||
<RiCloseLine size={14} />
|
||||
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -947,10 +1022,13 @@ export const TerminalView: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTab}
|
||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
className={cn(
|
||||
'ml-1 flex items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]',
|
||||
isMobile ? '!min-h-0 !min-w-0 h-8 w-8' : 'h-6.5 w-6.5'
|
||||
)}
|
||||
title="New tab"
|
||||
>
|
||||
<RiAddLine size={16} />
|
||||
<RiAddLine size={isMobile ? 18 : 16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -978,43 +1056,22 @@ export const TerminalView: React.FC = () => {
|
||||
>
|
||||
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
|
||||
{shouldRenderViewport ? (
|
||||
isMobile ? (
|
||||
<TerminalViewport
|
||||
key={viewportSessionKey}
|
||||
ref={(controller) => {
|
||||
terminalControllerRef.current = controller;
|
||||
}}
|
||||
sessionKey={viewportSessionKey}
|
||||
chunks={bufferChunks}
|
||||
onInput={handleViewportInput}
|
||||
onResize={handleViewportResize}
|
||||
theme={xtermTheme}
|
||||
fontFamily={resolvedFontStack}
|
||||
fontSize={terminalFontSize}
|
||||
enableTouchScroll={hasTouchInput}
|
||||
autoFocus={isTerminalVisible}
|
||||
keyboardAvoidTargetId={keyboardAvoidTargetId}
|
||||
/>
|
||||
) : (
|
||||
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
|
||||
<TerminalViewport
|
||||
key={viewportSessionKey}
|
||||
ref={(controller) => {
|
||||
terminalControllerRef.current = controller;
|
||||
}}
|
||||
sessionKey={viewportSessionKey}
|
||||
chunks={bufferChunks}
|
||||
onInput={handleViewportInput}
|
||||
onResize={handleViewportResize}
|
||||
theme={xtermTheme}
|
||||
fontFamily={resolvedFontStack}
|
||||
fontSize={terminalFontSize}
|
||||
enableTouchScroll={hasTouchInput}
|
||||
autoFocus={isTerminalVisible}
|
||||
keyboardAvoidTargetId={keyboardAvoidTargetId}
|
||||
/>
|
||||
</ScrollableOverlay>
|
||||
)
|
||||
<TerminalViewport
|
||||
key={viewportSessionKey}
|
||||
ref={(controller) => {
|
||||
terminalControllerRef.current = controller;
|
||||
}}
|
||||
sessionKey={viewportSessionKey}
|
||||
chunks={bufferChunks}
|
||||
onInput={handleViewportInput}
|
||||
onResize={handleViewportResize}
|
||||
theme={xtermTheme}
|
||||
fontFamily={resolvedFontStack}
|
||||
fontSize={terminalFontSize}
|
||||
enableTouchScroll={hasTouchInput}
|
||||
autoFocus={isTerminalVisible}
|
||||
keyboardAvoidTargetId={keyboardAvoidTargetId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{connectionError && (
|
||||
|
||||
@@ -59,6 +59,26 @@ export interface OpenChamberConfig {
|
||||
'setup-worktree'?: string[];
|
||||
projectNotes?: string;
|
||||
projectTodos?: OpenChamberProjectTodoItem[];
|
||||
projectActions?: OpenChamberProjectAction[];
|
||||
projectActionsPrimaryId?: string;
|
||||
}
|
||||
|
||||
export type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
|
||||
export interface OpenChamberProjectAction {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
icon?: string | null;
|
||||
platforms?: OpenChamberProjectActionPlatform[];
|
||||
autoOpenUrl?: boolean;
|
||||
openUrl?: string;
|
||||
desktopOpenSshForward?: string;
|
||||
}
|
||||
|
||||
export interface OpenChamberProjectActionsState {
|
||||
actions: OpenChamberProjectAction[];
|
||||
primaryActionId: string | null;
|
||||
}
|
||||
|
||||
export interface OpenChamberProjectTodoItem {
|
||||
@@ -75,6 +95,12 @@ export interface OpenChamberProjectNotesTodos {
|
||||
|
||||
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000;
|
||||
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
|
||||
|
||||
const OPENCHAMBER_ACTION_PLATFORM_SET = new Set<OpenChamberProjectActionPlatform>(['macos', 'linux', 'windows']);
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -344,6 +370,105 @@ const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[]
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique: OpenChamberProjectActionPlatform[] = [];
|
||||
const seen = new Set<OpenChamberProjectActionPlatform>();
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const normalized = entry.trim().toLowerCase() as OpenChamberProjectActionPlatform;
|
||||
if (!OPENCHAMBER_ACTION_PLATFORM_SET.has(normalized) || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
unique.push(normalized);
|
||||
}
|
||||
|
||||
return unique;
|
||||
};
|
||||
|
||||
const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sanitized: OpenChamberProjectAction[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = entry as {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
command?: unknown;
|
||||
icon?: unknown;
|
||||
platforms?: unknown;
|
||||
autoOpenUrl?: unknown;
|
||||
openUrl?: unknown;
|
||||
desktopOpenSshForward?: unknown;
|
||||
};
|
||||
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
const name = trimToMaxLength(typeof record.name === 'string' ? record.name.trim() : '', OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH);
|
||||
const command = trimToMaxLength(typeof record.command === 'string' ? record.command.trim() : '', OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH);
|
||||
|
||||
if (!id || !name || !command || seenIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
|
||||
const platforms = sanitizeProjectActionPlatforms(record.platforms);
|
||||
const autoOpenUrl = record.autoOpenUrl === true;
|
||||
const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
|
||||
const openUrl = trimToMaxLength(openUrlRaw, OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH);
|
||||
const desktopOpenSshForwardRaw = typeof record.desktopOpenSshForward === 'string'
|
||||
? record.desktopOpenSshForward.trim()
|
||||
: '';
|
||||
const desktopOpenSshForward = trimToMaxLength(
|
||||
desktopOpenSshForwardRaw,
|
||||
OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
|
||||
);
|
||||
|
||||
sanitized.push({
|
||||
id,
|
||||
name,
|
||||
command,
|
||||
icon: iconRaw || null,
|
||||
...(autoOpenUrl ? { autoOpenUrl: true } : {}),
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
|
||||
...(platforms.length > 0 ? { platforms } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const sanitizeProjectActionsState = (value: {
|
||||
actions?: unknown;
|
||||
primaryActionId?: unknown;
|
||||
} | null | undefined): OpenChamberProjectActionsState => {
|
||||
const actions = sanitizeProjectActions(value?.actions);
|
||||
const primaryRaw = typeof value?.primaryActionId === 'string' ? value.primaryActionId.trim() : '';
|
||||
const primaryActionId = primaryRaw && actions.some((entry) => entry.id === primaryRaw)
|
||||
? primaryRaw
|
||||
: null;
|
||||
|
||||
return {
|
||||
actions,
|
||||
primaryActionId,
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeProjectNotesAndTodos = (value: {
|
||||
notes?: unknown;
|
||||
todos?: unknown;
|
||||
@@ -504,6 +629,29 @@ export async function saveProjectNotesAndTodos(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjectActionsState(project: ProjectRef): Promise<OpenChamberProjectActionsState> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeProjectActionsState({
|
||||
actions: config?.projectActions,
|
||||
primaryActionId: config?.projectActionsPrimaryId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveProjectActionsState(
|
||||
project: ProjectRef,
|
||||
value: OpenChamberProjectActionsState
|
||||
): Promise<boolean> {
|
||||
const sanitized = sanitizeProjectActionsState({
|
||||
actions: value.actions,
|
||||
primaryActionId: value.primaryActionId,
|
||||
});
|
||||
|
||||
return updateOpenChamberConfig(project, {
|
||||
projectActions: sanitized.actions,
|
||||
projectActionsPrimaryId: sanitized.primaryActionId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute variables in a command string.
|
||||
* Supported variables:
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -36,6 +36,7 @@ interface TerminalStore {
|
||||
|
||||
createTab: (directory: string) => string;
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
@@ -192,6 +193,43 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const normalizedLabel = label.trim();
|
||||
if (!normalizedLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (existing.tabs[idx]?.label === normalizedLabel) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
label: normalizedLabel,
|
||||
};
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: nextTabs,
|
||||
});
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: async (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
@@ -258,7 +296,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const shouldResetBuffer = tab.terminalSessionId !== sessionId;
|
||||
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
|
||||
|
||||
const nextTab: TerminalTab = {
|
||||
...tab,
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
|
||||
/* Allow system context menu only when explicitly requested via the "More" button */
|
||||
:root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu {
|
||||
-webkit-touch-callout: default;
|
||||
|
||||
@@ -11528,6 +11528,13 @@ async function main(options = {}) {
|
||||
const terminalSessions = new Map();
|
||||
const MAX_TERMINAL_SESSIONS = 20;
|
||||
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
|
||||
const sanitizeTerminalEnv = (env) => {
|
||||
const next = { ...env };
|
||||
delete next.BASH_XTRACEFD;
|
||||
delete next.BASH_ENV;
|
||||
delete next.ENV;
|
||||
return next;
|
||||
};
|
||||
const terminalInputCapabilities = {
|
||||
input: {
|
||||
preferred: 'ws',
|
||||
@@ -11751,7 +11758,7 @@ async function main(options = {}) {
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const envPath = buildAugmentedPath();
|
||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
||||
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
|
||||
|
||||
const pty = await getPtyProvider();
|
||||
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
||||
@@ -11965,7 +11972,7 @@ async function main(options = {}) {
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const envPath = buildAugmentedPath();
|
||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
||||
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
|
||||
|
||||
const pty = await getPtyProvider();
|
||||
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
||||
|
||||
+11
-15
@@ -13,20 +13,16 @@ declare global {
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
||||
|
||||
registerSW({
|
||||
onRegistered(registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Periodic update check (best-effort)
|
||||
setInterval(() => {
|
||||
void registration.update();
|
||||
}, 60 * 60 * 1000);
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.warn('[PWA] service worker registration failed:', error);
|
||||
},
|
||||
});
|
||||
if (import.meta.env.PROD) {
|
||||
registerSW({
|
||||
onRegisterError(error: unknown) {
|
||||
console.warn('[PWA] service worker registration failed:', error);
|
||||
},
|
||||
});
|
||||
} else if ('serviceWorker' in navigator) {
|
||||
void navigator.serviceWorker.getRegistrations()
|
||||
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
import('@openchamber/ui/main');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { themeStoragePlugin } from '../../vite-theme-plugin';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||
const pwaDevEnabled = process.env.OPENCHAMBER_DISABLE_PWA_DEV !== '1';
|
||||
const reactScanToggle = (process.env.VITE_ENABLE_REACT_SCAN ?? '').toLowerCase();
|
||||
const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes';
|
||||
|
||||
@@ -53,7 +54,7 @@ export default defineConfig({
|
||||
injectionPoint: undefined,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
enabled: pwaDevEnabled,
|
||||
type: 'module',
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const useDetachedChildren = process.platform === 'darwin';
|
||||
|
||||
function run(label, command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
detached: useDetachedChildren,
|
||||
...options,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[dev:web:full] Failed to start ${label}:`, error);
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (useDetachedChildren && process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForFirstBuildSuccess(buildChild) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
|
||||
const settleResolve = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const settleReject = (error) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
|
||||
const onOutput = (chunk, source) => {
|
||||
const text = chunk.toString();
|
||||
if (source === 'stdout') {
|
||||
stdoutBuffer += text;
|
||||
if (stdoutBuffer.length > 8000) {
|
||||
stdoutBuffer = stdoutBuffer.slice(-4000);
|
||||
}
|
||||
} else {
|
||||
stderrBuffer += text;
|
||||
if (stderrBuffer.length > 8000) {
|
||||
stderrBuffer = stderrBuffer.slice(-4000);
|
||||
}
|
||||
}
|
||||
|
||||
if (/\bbuilt in\b/i.test(text) || /watching for file changes/i.test(text)) {
|
||||
settleResolve();
|
||||
}
|
||||
};
|
||||
|
||||
const onStdout = (chunk) => onOutput(chunk, 'stdout');
|
||||
const onStderr = (chunk) => onOutput(chunk, 'stderr');
|
||||
|
||||
const onExit = (code, signal) => {
|
||||
if (done) return;
|
||||
const suffix = signal ? `signal=${signal}` : `code=${code ?? 'null'}`;
|
||||
settleReject(new Error(`Build watcher exited before first successful build (${suffix}).`));
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
settleReject(error);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
buildChild.stdout?.off('data', onStdout);
|
||||
buildChild.stderr?.off('data', onStderr);
|
||||
buildChild.off('exit', onExit);
|
||||
buildChild.off('error', onError);
|
||||
};
|
||||
|
||||
buildChild.stdout?.on('data', onStdout);
|
||||
buildChild.stderr?.on('data', onStderr);
|
||||
buildChild.on('exit', onExit);
|
||||
buildChild.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
let api = null;
|
||||
const build = run('build', 'bun', ['run', '--cwd', 'packages/web', 'build:watch']);
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await Promise.all([stopChildTree(api), stopChildTree(build)]);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function onChildExit(label) {
|
||||
return (code, signal) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0 || signal) {
|
||||
console.error(`[dev:web:full] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
|
||||
shutdown(typeof code === 'number' ? code : 1).catch(() => process.exit(1));
|
||||
return;
|
||||
}
|
||||
|
||||
shutdown(0).catch(() => process.exit(1));
|
||||
};
|
||||
}
|
||||
|
||||
build.on('exit', onChildExit('build'));
|
||||
|
||||
waitForFirstBuildSuccess(build)
|
||||
.then(() => {
|
||||
if (shuttingDown || api) {
|
||||
return;
|
||||
}
|
||||
console.log('[dev:web:full] Initial frontend build ready, starting API watcher...');
|
||||
api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch']);
|
||||
api.on('exit', onChildExit('api'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[dev:web:full] Failed waiting for initial frontend build:', error.message || error);
|
||||
shutdown(1).catch(() => process.exit(1));
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
shutdown(130).catch(() => process.exit(130));
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown(143).catch(() => process.exit(143));
|
||||
});
|
||||
|
||||
process.on('SIGHUP', () => {
|
||||
shutdown(129).catch(() => process.exit(129));
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const useDetachedChildren = process.platform === 'darwin';
|
||||
|
||||
function run(label, command, args, env = {}, options = {}) {
|
||||
return spawn(command, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...env },
|
||||
detached: useDetachedChildren,
|
||||
}).on('error', (error) => {
|
||||
console.error(`[dev:web:hmr] Failed to start ${label}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (useDetachedChildren && process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const uiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180';
|
||||
const backendPort = process.env.OPENCHAMBER_HMR_API_PORT || '3902';
|
||||
|
||||
const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], {
|
||||
OPENCHAMBER_PORT: backendPort,
|
||||
});
|
||||
const vite = run(
|
||||
'vite',
|
||||
'bun',
|
||||
['x', 'vite', '--host', '127.0.0.1', '--port', uiPort, '--strictPort'],
|
||||
{
|
||||
OPENCHAMBER_PORT: backendPort,
|
||||
OPENCHAMBER_DISABLE_PWA_DEV: '1',
|
||||
},
|
||||
{ cwd: path.join(repoRoot, 'packages/web') },
|
||||
);
|
||||
|
||||
console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);
|
||||
console.log(`[dev:web:hmr] API: http://127.0.0.1:${backendPort}`);
|
||||
console.log('[dev:web:hmr] IMPORTANT: open UI URL above for HMR; backend URL has no HMR');
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await Promise.all([stopChildTree(api), stopChildTree(vite)]);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function onChildExit(label) {
|
||||
return (code, signal) => {
|
||||
if (shuttingDown) return;
|
||||
|
||||
if (code !== 0 || signal) {
|
||||
console.error(`[dev:web:hmr] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
|
||||
shutdown(typeof code === 'number' ? code : 1).catch(() => process.exit(1));
|
||||
return;
|
||||
}
|
||||
|
||||
shutdown(0).catch(() => process.exit(1));
|
||||
};
|
||||
}
|
||||
|
||||
api.on('exit', onChildExit('api'));
|
||||
vite.on('exit', onChildExit('vite'));
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
shutdown(130).catch(() => process.exit(130));
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown(143).catch(() => process.exit(143));
|
||||
});
|
||||
process.on('SIGHUP', () => {
|
||||
shutdown(129).catch(() => process.exit(129));
|
||||
});
|
||||
Reference in New Issue
Block a user