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",
|
"postinstall": "patch-package",
|
||||||
"dev:web": "bun run --cwd packages/web build:watch",
|
"dev:web": "bun run --cwd packages/web build:watch",
|
||||||
"dev:web:server": "bun run --cwd packages/web dev:server: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",
|
"start:web": "bun run --cwd packages/web start",
|
||||||
"pack:web": "bun pm pack --cwd packages/web",
|
"pack:web": "bun pm pack --cwd packages/web",
|
||||||
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||||
|
|||||||
@@ -13,10 +13,70 @@ function spawnProcess(command, args, opts = {}) {
|
|||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
...opts,
|
...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() {
|
async function main() {
|
||||||
const tauriProcess = spawnProcess('bun', [
|
const tauriProcess = spawnProcess('bun', [
|
||||||
'--cwd',
|
'--cwd',
|
||||||
@@ -37,19 +97,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
cleaning = true;
|
cleaning = true;
|
||||||
|
|
||||||
const stopChild = (child, label) => {
|
await stopChildTree(tauriProcess);
|
||||||
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');
|
|
||||||
|
|
||||||
process.exit(typeof code === 'number' ? code : 0);
|
process.exit(typeof code === 'number' ? code : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import path from 'node:path';
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const DESKTOP_DEV_PORT = 3901;
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -48,11 +50,12 @@ const run = (cmd, args, cwd) => {
|
|||||||
console.log('[desktop] ensuring sidecar + web-dist...');
|
console.log('[desktop] ensuring sidecar + web-dist...');
|
||||||
run('node', ['./scripts/build-sidecar.mjs'], desktopDir);
|
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,
|
cwd: repoRoot,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCHAMBER_HOST: '127.0.0.1',
|
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'], {
|
const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], {
|
||||||
cwd: webDir,
|
cwd: webDir,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
env: {
|
env: {
|
||||||
...process.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',
|
||||||
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;
|
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;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
|
||||||
try {
|
await requestApiShutdown();
|
||||||
apiChild.kill('SIGTERM');
|
await Promise.all([stopChildTree(webChild), stopChildTree(apiChild)]);
|
||||||
} catch {}
|
process.exit(exitCode);
|
||||||
|
|
||||||
try {
|
|
||||||
webChild.kill('SIGTERM');
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExit = (label) => (code, signal) => {
|
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'})`);
|
console.error(`[desktop] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
shutdown();
|
shutdown(typeof code === 'number' ? code : 1).catch((error) => {
|
||||||
process.exit(typeof code === 'number' ? code : 1);
|
console.error('[desktop] shutdown failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
apiChild.on('exit', handleExit('API server'));
|
apiChild.on('exit', handleExit('API server'));
|
||||||
@@ -111,13 +180,18 @@ const handleError = (label) => (error) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(`[desktop] failed to start ${label}:`, error);
|
console.error(`[desktop] failed to start ${label}:`, error);
|
||||||
shutdown();
|
shutdown(1).catch(() => process.exit(1));
|
||||||
process.exit(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
apiChild.on('error', handleError('API server'));
|
apiChild.on('error', handleError('API server'));
|
||||||
webChild.on('error', handleError('Vite server'));
|
webChild.on('error', handleError('Vite server'));
|
||||||
|
|
||||||
process.on('SIGINT', shutdown);
|
process.on('SIGINT', () => {
|
||||||
process.on('SIGTERM', shutdown);
|
shutdown(130).catch(() => process.exit(130));
|
||||||
process.on('exit', shutdown);
|
});
|
||||||
|
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();
|
let handle = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let local_url = if cfg!(debug_assertions) {
|
let local_url = if cfg!(debug_assertions) {
|
||||||
let dev_url = "http://127.0.0.1:3001";
|
let dev_url = "http://127.0.0.1:3901".to_string();
|
||||||
if wait_for_health(dev_url).await {
|
if wait_for_health(&dev_url).await {
|
||||||
dev_url.to_string()
|
dev_url.to_string()
|
||||||
} else {
|
} else {
|
||||||
match spawn_local_server(&handle).await {
|
match spawn_local_server(&handle).await {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
||||||
"beforeBuildCommand": "bun run build:sidecar",
|
"beforeBuildCommand": "bun run build:sidecar",
|
||||||
"devUrl": "http://127.0.0.1:3001",
|
"devUrl": "http://127.0.0.1:3901",
|
||||||
"frontendDist": "../noop-dist"
|
"frontendDist": "../noop-dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiArrowDownLine } from '@remixicon/react';
|
import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { getMemoryLimits } from '@/stores/types/sessionTypes';
|
import { getMemoryLimits } from '@/stores/types/sessionTypes';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ButtonSmall } from '@/components/ui/button-small';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
import { TimelineDialog } from './TimelineDialog';
|
import { TimelineDialog } from './TimelineDialog';
|
||||||
import type { PermissionRequest } from '@/types/permission';
|
import type { PermissionRequest } from '@/types/permission';
|
||||||
@@ -76,6 +77,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
openNewSessionDraft,
|
openNewSessionDraft,
|
||||||
|
setCurrentSession,
|
||||||
trimToViewportWindow,
|
trimToViewportWindow,
|
||||||
newSessionDraft,
|
newSessionDraft,
|
||||||
} = useSessionStore(
|
} = useSessionStore(
|
||||||
@@ -86,6 +88,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
loadMoreMessages: state.loadMoreMessages,
|
loadMoreMessages: state.loadMoreMessages,
|
||||||
updateViewportAnchor: state.updateViewportAnchor,
|
updateViewportAnchor: state.updateViewportAnchor,
|
||||||
openNewSessionDraft: state.openNewSessionDraft,
|
openNewSessionDraft: state.openNewSessionDraft,
|
||||||
|
setCurrentSession: state.setCurrentSession,
|
||||||
trimToViewportWindow: state.trimToViewportWindow,
|
trimToViewportWindow: state.trimToViewportWindow,
|
||||||
newSessionDraft: state.newSessionDraft,
|
newSessionDraft: state.newSessionDraft,
|
||||||
}))
|
}))
|
||||||
@@ -112,6 +115,8 @@ export const ChatContainer: React.FC = () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
|
|
||||||
const blockingRequestState = useSessionStore(
|
const blockingRequestState = useSessionStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
sessions: state.sessions,
|
sessions: state.sessions,
|
||||||
@@ -168,6 +173,42 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
||||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
openNewSessionDraft();
|
openNewSessionDraft();
|
||||||
@@ -487,9 +528,10 @@ export const ChatContainer: React.FC = () => {
|
|||||||
if (!hasMessagesEntry) {
|
if (!hasMessagesEntry) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
|
{returnToParentButton}
|
||||||
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
||||||
<div className="chat-message-column space-y-4">
|
<div className="chat-message-column space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[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"
|
className="relative flex flex-col h-full bg-background transform-gpu"
|
||||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
|
{returnToParentButton}
|
||||||
{!isDesktopExpandedInput ? (
|
{!isDesktopExpandedInput ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<ChatEmptyState />
|
<ChatEmptyState />
|
||||||
@@ -539,6 +582,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
className="relative flex flex-col h-full bg-background"
|
className="relative flex flex-col h-full bg-background"
|
||||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
|
{returnToParentButton}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative min-h-0',
|
'relative min-h-0',
|
||||||
|
|||||||
@@ -11,6 +11,46 @@ interface WorkingPlaceholderProps {
|
|||||||
|
|
||||||
const STATUS_DISPLAY_TIME_MS = 1200;
|
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({
|
export function WorkingPlaceholder({
|
||||||
isWorking,
|
isWorking,
|
||||||
statusText,
|
statusText,
|
||||||
@@ -26,26 +66,19 @@ export function WorkingPlaceholder({
|
|||||||
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Countdown state for retry mode
|
// 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);
|
const [retryCountdown, setRetryCountdown] = React.useState<number | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const next = retryInfo?.next;
|
const rawNext = retryInfo?.next;
|
||||||
if (!next || next <= 0) {
|
if (!rawNext || rawNext <= 0) {
|
||||||
retryNextRef.current = null;
|
|
||||||
retryStartRef.current = null;
|
|
||||||
setRetryCountdown(null);
|
setRetryCountdown(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a fresh countdown when next value or attempt changes
|
const retryTargetAt = toRetryTargetTimestamp(rawNext);
|
||||||
retryNextRef.current = next;
|
|
||||||
retryStartRef.current = Date.now();
|
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const elapsed = Date.now() - (retryStartRef.current ?? Date.now());
|
const remaining = Math.max(0, retryTargetAt - Date.now());
|
||||||
const remaining = Math.max(0, next - elapsed);
|
|
||||||
setRetryCountdown(Math.ceil(remaining / 1000));
|
setRetryCountdown(Math.ceil(remaining / 1000));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,7 +184,9 @@ export function WorkingPlaceholder({
|
|||||||
// Retry state: show countdown and attempt info
|
// Retry state: show countdown and attempt info
|
||||||
if (retryInfo) {
|
if (retryInfo) {
|
||||||
const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : '';
|
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}...`;
|
const retryText = `Retrying${countdownLabel}${attemptLabel}...`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -351,7 +351,11 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
|
|||||||
<RiArrowDownSLine className="h-4 w-4" />
|
<RiArrowDownSLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</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()}>
|
<DropdownMenuItem className="flex items-center gap-2" onClick={() => void handleCopyPath()}>
|
||||||
<RiFileCopyLine className="h-4 w-4" />
|
<RiFileCopyLine className="h-4 w-4" />
|
||||||
<span className="typography-ui-label text-foreground">Copy Path</span>
|
<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 type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||||
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
||||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||||
|
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
||||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||||
|
|
||||||
@@ -165,12 +166,13 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
return state.messages.get(currentSessionId);
|
return state.messages.get(currentSessionId);
|
||||||
});
|
});
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
const activeProjectLabel = useProjectsStore((state) => {
|
const activeProject = useProjectsStore((state) => {
|
||||||
if (!state.activeProjectId) {
|
if (!state.activeProjectId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
|
||||||
const activeProject = state.projects.find((project) => project.id === state.activeProjectId);
|
});
|
||||||
|
const activeProjectLabel = React.useMemo(() => {
|
||||||
if (!activeProject) {
|
if (!activeProject) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -182,7 +184,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
|
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
|
||||||
return pathSegments[pathSegments.length - 1] ?? null;
|
return pathSegments[pathSegments.length - 1] ?? null;
|
||||||
});
|
}, [activeProject]);
|
||||||
const quotaResults = useQuotaStore((state) => state.results);
|
const quotaResults = useQuotaStore((state) => state.results);
|
||||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||||
@@ -491,6 +493,17 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
return worktreeDirectory || sessionDirectory || draftDirectory;
|
return worktreeDirectory || sessionDirectory || draftDirectory;
|
||||||
}, [draftDirectory, sessionDirectory, worktreeDirectory]);
|
}, [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 [planTabAvailable, setPlanTabAvailable] = React.useState(false);
|
||||||
const showPlanTab = planTabAvailable;
|
const showPlanTab = planTabAvailable;
|
||||||
@@ -967,6 +980,14 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeProjectRef && actionDirectory && (
|
||||||
|
<ProjectActionsButton
|
||||||
|
projectRef={activeProjectRef}
|
||||||
|
directory={actionDirectory}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tabs.length > 0 && (
|
{tabs.length > 0 && (
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
||||||
{tabs.map((tab) => renderTab(tab))}
|
{tabs.map((tab) => renderTab(tab))}
|
||||||
@@ -1505,6 +1526,15 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<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) */}
|
{/* Mobile Services Menu (Usage + MCP) */}
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
|
|||||||
|
|
||||||
type NavRailActionButtonProps = {
|
type NavRailActionButtonProps = {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tooltipLabel: string;
|
tooltipLabel: string;
|
||||||
@@ -76,6 +77,7 @@ type NavRailActionButtonProps = {
|
|||||||
|
|
||||||
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
||||||
onClick,
|
onClick,
|
||||||
|
disabled = false,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
icon,
|
icon,
|
||||||
tooltipLabel,
|
tooltipLabel,
|
||||||
@@ -85,12 +87,56 @@ const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
|||||||
showExpandedContent,
|
showExpandedContent,
|
||||||
actionTextVisible,
|
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 = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={clearPointerPress}
|
||||||
|
onPointerLeave={clearPointerPress}
|
||||||
|
onClick={handleClick}
|
||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{showExpandedContent && (
|
{showExpandedContent && (
|
||||||
<span
|
<span
|
||||||
@@ -423,6 +469,19 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
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 isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
|
||||||
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
|
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
|
||||||
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
||||||
@@ -473,6 +532,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore();
|
||||||
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||||
|
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
|
||||||
|
|
||||||
const [editingProject, setEditingProject] = React.useState<{
|
const [editingProject, setEditingProject] = React.useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -676,6 +736,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
||||||
showExpandedContent ? 'items-stretch' : 'items-center',
|
showExpandedContent ? 'items-stretch' : 'items-center',
|
||||||
|
navRailInteractionBlocked && 'pointer-events-none',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
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')}>
|
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
|
disabled={navRailInteractionBlocked}
|
||||||
ariaLabel="Add project"
|
ariaLabel="Add project"
|
||||||
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Add project"
|
tooltipLabel="Add project"
|
||||||
@@ -742,6 +804,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
{(updateAvailable || updateDownloaded) && (
|
{(updateAvailable || updateDownloaded) && (
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={() => setUpdateDialogOpen(true)}
|
onClick={() => setUpdateDialogOpen(true)}
|
||||||
|
disabled={navRailInteractionBlocked}
|
||||||
ariaLabel="Update available"
|
ariaLabel="Update available"
|
||||||
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Update available"
|
tooltipLabel="Update available"
|
||||||
@@ -754,6 +817,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={() => setAboutDialogOpen(true)}
|
onClick={() => setAboutDialogOpen(true)}
|
||||||
|
disabled={navRailInteractionBlocked}
|
||||||
ariaLabel="About"
|
ariaLabel="About"
|
||||||
icon={<RiInformationLine className={navRailActionIconClass} />}
|
icon={<RiInformationLine className={navRailActionIconClass} />}
|
||||||
tooltipLabel="About OpenChamber"
|
tooltipLabel="About OpenChamber"
|
||||||
@@ -766,6 +830,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
{!mobile && (
|
{!mobile && (
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={toggleHelpDialog}
|
onClick={toggleHelpDialog}
|
||||||
|
disabled={navRailInteractionBlocked}
|
||||||
ariaLabel="Keyboard shortcuts"
|
ariaLabel="Keyboard shortcuts"
|
||||||
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Shortcuts"
|
tooltipLabel="Shortcuts"
|
||||||
@@ -779,6 +844,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
|
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={() => setSettingsDialogOpen(true)}
|
onClick={() => setSettingsDialogOpen(true)}
|
||||||
|
disabled={navRailInteractionBlocked}
|
||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
||||||
tooltipLabel="Settings"
|
tooltipLabel="Settings"
|
||||||
@@ -793,6 +859,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
|||||||
{!mobile && (
|
{!mobile && (
|
||||||
<NavRailActionButton
|
<NavRailActionButton
|
||||||
onClick={toggleNavRail}
|
onClick={toggleNavRail}
|
||||||
|
disabled={navRailInteractionBlocked}
|
||||||
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||||
icon={expanded
|
icon={expanded
|
||||||
? <RiMenuFoldLine className={navRailActionIconClass} />
|
? <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 { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||||
import { RiCloseLine } from '@remixicon/react';
|
import { RiCloseLine } from '@remixicon/react';
|
||||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||||
|
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||||
|
|
||||||
export const ProjectsPage: React.FC = () => {
|
export const ProjectsPage: React.FC = () => {
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
@@ -45,6 +46,13 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
|
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
setName('');
|
setName('');
|
||||||
@@ -149,7 +157,6 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Worktree Group */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<section className="px-2 pb-2 pt-0">
|
||||||
|
{selectedProjectRef && <ProjectActionsSection projectRef={selectedProjectRef} />}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Worktree Group */}
|
{/* Worktree Group */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="mb-1 px-1">
|
<div className="mb-1 px-1">
|
||||||
@@ -378,7 +392,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<section className="px-2 pb-2 pt-0">
|
<section className="px-2 pb-2 pt-0">
|
||||||
<WorktreeSectionContent projectRef={{ id: selectedProject.id, path: selectedProject.path }} />
|
{selectedProjectRef && <WorktreeSectionContent projectRef={selectedProjectRef} />}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ type TerminalController = {
|
|||||||
fit: () => void;
|
fit: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TerminalWithViewport = {
|
||||||
|
scrollToBottom?: () => void;
|
||||||
|
getViewportY?: () => number;
|
||||||
|
hasSelection?: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FitAddonWithObserveResize = FitAddon & {
|
||||||
|
observeResize?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
interface TerminalViewportProps {
|
interface TerminalViewportProps {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
chunks: TerminalChunk[];
|
chunks: TerminalChunk[];
|
||||||
@@ -97,6 +107,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
const writeScheduledRef = React.useRef<number | null>(null);
|
const writeScheduledRef = React.useRef<number | null>(null);
|
||||||
const isWritingRef = React.useRef(false);
|
const isWritingRef = React.useRef(false);
|
||||||
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
|
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
|
||||||
|
const followOutputRef = React.useRef(true);
|
||||||
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
||||||
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
||||||
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
||||||
@@ -313,7 +324,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!useHiddenInputOverlay || !container) {
|
if (!useHiddenInputOverlay || !container || enableTouchScroll) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +349,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
||||||
};
|
};
|
||||||
}, [useHiddenInputOverlay, focusHiddenInput]);
|
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput]);
|
||||||
|
|
||||||
const getTerminalSelectionText = React.useCallback((): string => {
|
const getTerminalSelectionText = React.useCallback((): string => {
|
||||||
const terminal = terminalRef.current as unknown as {
|
const terminal = terminalRef.current as unknown as {
|
||||||
@@ -834,10 +845,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
scrollByPixels(deltaPixels);
|
scrollByPixels(deltaPixels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = (event: TouchEvent) => {
|
const handleTouchEnd = (event: TouchEvent) => {
|
||||||
const wasTap = !state.didMove;
|
const wasTap = !state.didMove;
|
||||||
|
|
||||||
|
|
||||||
state.lastY = null;
|
state.lastY = null;
|
||||||
state.lastTime = null;
|
state.lastTime = null;
|
||||||
|
|
||||||
@@ -918,6 +928,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
let localResizeObserver: ResizeObserver | null = null;
|
let localResizeObserver: ResizeObserver | null = null;
|
||||||
let localTextareaObserver: MutationObserver | null = null;
|
let localTextareaObserver: MutationObserver | null = null;
|
||||||
let localDisposables: Array<{ dispose: () => void }> = [];
|
let localDisposables: Array<{ dispose: () => void }> = [];
|
||||||
|
let restorePatchedScrollToBottom: (() => void) | null = null;
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -956,9 +967,23 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
|
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false);
|
||||||
|
|
||||||
const terminal = new GhosttyTerminal(options);
|
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();
|
const fitAddon = new FitAddon();
|
||||||
|
|
||||||
@@ -999,11 +1024,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
|
const fitAddonWithResize = fitAddon as FitAddonWithObserveResize;
|
||||||
|
if (typeof fitAddonWithResize.observeResize === 'function') {
|
||||||
|
fitAddonWithResize.observeResize();
|
||||||
|
}
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
localDisposables = [
|
localDisposables = [
|
||||||
terminal.onData((data: string) => {
|
terminal.onData((data: string) => {
|
||||||
inputHandlerRef.current(data);
|
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(() => {
|
localResizeObserver = new ResizeObserver(() => {
|
||||||
@@ -1035,6 +1080,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
window.removeEventListener('blur', handleWindowBlur);
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
localDisposables.forEach((disposable) => disposable.dispose());
|
localDisposables.forEach((disposable) => disposable.dispose());
|
||||||
|
restorePatchedScrollToBottom?.();
|
||||||
|
restorePatchedScrollToBottom = null;
|
||||||
if (localTerminalTextarea) {
|
if (localTerminalTextarea) {
|
||||||
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
|
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
|
||||||
localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur);
|
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)}
|
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
||||||
style={{ backgroundColor: theme.background }}
|
style={{ backgroundColor: theme.background }}
|
||||||
onTouchStart={(event) => {
|
onTouchStart={(event) => {
|
||||||
if (useHiddenInputOverlay) {
|
if (!useHiddenInputOverlay || enableTouchScroll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasCopyableSelectionInViewport()) {
|
||||||
const touch = event.touches?.[0];
|
const touch = event.touches?.[0];
|
||||||
focusHiddenInput(touch?.clientX, touch?.clientY);
|
focusHiddenInput(touch?.clientX, touch?.clientY);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (useHiddenInputOverlay) {
|
if (useHiddenInputOverlay) {
|
||||||
|
if (enableTouchScroll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasCopyableSelectionInViewport()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
focusHiddenInput(event.clientX, event.clientY);
|
focusHiddenInput(event.clientX, event.clientY);
|
||||||
} else {
|
} else {
|
||||||
terminalRef.current?.focus();
|
terminalRef.current?.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (!enableTouchScroll) {
|
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
|
||||||
void copySelectionToClipboard();
|
void copySelectionToClipboard();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTouchEnd={() => {
|
onTouchEnd={() => {
|
||||||
if (!enableTouchScroll) {
|
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
|
||||||
void copySelectionToClipboard();
|
void copySelectionToClipboard();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function DropdownMenuTrigger({
|
|||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +43,7 @@ function DropdownMenuContent({
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--surface-elevated)',
|
backgroundColor: 'var(--surface-elevated)',
|
||||||
color: 'var(--surface-elevated-foreground)',
|
color: 'var(--surface-elevated-foreground)',
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
"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.Portal>
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-md"
|
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
|
<DialogPrimitive.Content
|
||||||
aria-describedby={descriptionId}
|
aria-describedby={descriptionId}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
||||||
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
|
'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 { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions';
|
||||||
import { convertThemeToXterm } from '@/lib/terminalTheme';
|
import { convertThemeToXterm } from '@/lib/terminalTheme';
|
||||||
import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport';
|
import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -86,9 +85,11 @@ export const TerminalView: React.FC = () => {
|
|||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const { monoFont } = useFontPreferences();
|
const { monoFont } = useFontPreferences();
|
||||||
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
||||||
|
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
|
||||||
|
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
|
||||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
// Tabs are supported for web + desktop runtimes (not VSCode).
|
// Tabs are supported for web + desktop runtimes, including mobile (not VSCode).
|
||||||
const enableTabs = !isMobile && runtime.platform !== 'vscode';
|
const enableTabs = runtime.platform !== 'vscode';
|
||||||
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
||||||
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||||
|
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
|
||||||
const keyboardAvoidTargetId = React.useId();
|
const keyboardAvoidTargetId = React.useId();
|
||||||
|
|
||||||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
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 rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
||||||
const rehydratedSnapshotTakenRef = React.useRef(false);
|
const rehydratedSnapshotTakenRef = React.useRef(false);
|
||||||
|
|
||||||
|
const focusTerminalWhenWindowActive = React.useCallback(() => {
|
||||||
|
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalControllerRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!terminalHydrated) {
|
if (!terminalHydrated) {
|
||||||
return;
|
return;
|
||||||
@@ -263,7 +272,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
setConnecting(directory, tabId, false);
|
setConnecting(directory, tabId, false);
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
terminalControllerRef.current?.focus();
|
focusTerminalWhenWindowActive();
|
||||||
|
|
||||||
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
||||||
// until the first output arrives. Nudge with a newline once.
|
// until the first output arrives. Nudge with a newline once.
|
||||||
@@ -292,6 +301,10 @@ export const TerminalView: React.FC = () => {
|
|||||||
const exitCode =
|
const exitCode =
|
||||||
typeof event.exitCode === 'number' ? event.exitCode : null;
|
typeof event.exitCode === 'number' ? event.exitCode : null;
|
||||||
const signal = typeof event.signal === 'number' ? event.signal : 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(
|
appendToBuffer(
|
||||||
directory,
|
directory,
|
||||||
tabId,
|
tabId,
|
||||||
@@ -301,7 +314,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
setTabSessionId(directory, tabId, null);
|
setTabSessionId(directory, tabId, null);
|
||||||
setConnecting(directory, tabId, false);
|
setConnecting(directory, tabId, false);
|
||||||
setConnectionError('Terminal session ended');
|
setConnectionError(isActionTab ? null : 'Terminal session ended');
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
disconnectStream();
|
disconnectStream();
|
||||||
break;
|
break;
|
||||||
@@ -335,7 +348,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
activeTerminalIdRef.current = null;
|
activeTerminalIdRef.current = null;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[appendToBuffer, disconnectStream, setConnecting, setTabSessionId, terminal]
|
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -375,6 +388,8 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||||
let terminalId = tab?.terminalSessionId ?? null;
|
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 =
|
const shouldNudgeExisting =
|
||||||
Boolean(terminalId) &&
|
Boolean(terminalId) &&
|
||||||
@@ -386,6 +401,11 @@ export const TerminalView: React.FC = () => {
|
|||||||
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||||||
|
|
||||||
if (!terminalId) {
|
if (!terminalId) {
|
||||||
|
if (isActionTab && hasBufferedOutput) {
|
||||||
|
setConnecting(directory, tabId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
setConnecting(directory, tabId, true);
|
setConnecting(directory, tabId, true);
|
||||||
@@ -473,27 +493,27 @@ export const TerminalView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
terminalControllerRef.current?.focus();
|
focusTerminalWhenWindowActive();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rafId = window.requestAnimationFrame(() => {
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
terminalControllerRef.current?.focus();
|
focusTerminalWhenWindowActive();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.cancelAnimationFrame(rafId);
|
window.cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}, [activeTabId, isTerminalVisible]);
|
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible]);
|
||||||
|
|
||||||
const handleRestart = React.useCallback(async () => {
|
const handleRestart = React.useCallback(async () => {
|
||||||
if (!effectiveDirectory) return;
|
if (!effectiveDirectory) return;
|
||||||
if (isRestarting) return;
|
if (isRestarting) return;
|
||||||
|
|
||||||
const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory);
|
const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory);
|
||||||
const tabId = isMobile
|
const tabId = enableTabs
|
||||||
? (state?.tabs[0]?.id ?? null)
|
? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null)
|
||||||
: (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null);
|
: (state?.tabs[0]?.id ?? null);
|
||||||
if (!tabId) return;
|
if (!tabId) return;
|
||||||
|
|
||||||
setIsRestarting(true);
|
setIsRestarting(true);
|
||||||
@@ -510,7 +530,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
}
|
}
|
||||||
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, isMobile, isRestarting]);
|
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting]);
|
||||||
|
|
||||||
const handleHardRestart = React.useCallback(async () => {
|
const handleHardRestart = React.useCallback(async () => {
|
||||||
// Keep semantics: “close tab -> new clean tab”.
|
// Keep semantics: “close tab -> new clean tab”.
|
||||||
@@ -735,7 +755,29 @@ export const TerminalView: React.FC = () => {
|
|||||||
return `${directoryPart}::${tabPart}::${terminalPart}`;
|
return `${directoryPart}::${tabPart}::${terminalPart}`;
|
||||||
}, [effectiveDirectory, activeTabId, terminalSessionId]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!isTerminalVisible) {
|
if (!isTerminalVisible) {
|
||||||
@@ -751,7 +793,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const rafId = window.requestAnimationFrame(() => {
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
fitOnce();
|
fitOnce();
|
||||||
controller.focus();
|
focusTerminalWhenWindowActive();
|
||||||
});
|
});
|
||||||
const timeoutIds = [220, 400].map((delay) => window.setTimeout(fitOnce, delay));
|
const timeoutIds = [220, 400].map((delay) => window.setTimeout(fitOnce, delay));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -760,7 +802,35 @@ export const TerminalView: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
fitOnce();
|
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) {
|
if (!hasActiveContext) {
|
||||||
return (
|
return (
|
||||||
@@ -901,18 +971,19 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
<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 ? (
|
{enableTabs && directoryTerminalState ? (
|
||||||
<div className="mt-2 pl-1 pr-1 flex items-center gap-2">
|
<div className={cn('pl-1 pr-1 flex items-center gap-2', isMobile ? 'mt-1' : 'mt-2')}>
|
||||||
<div className="min-w-0 flex-1 overflow-x-auto pb-1">
|
<div className={cn('min-w-0 flex-1 overflow-x-auto', isMobile ? 'pb-0.5' : 'pb-1')}>
|
||||||
<div className="flex w-max items-center gap-1 pr-1">
|
<div className={cn('flex w-max items-center pr-1', isMobile ? 'gap-1' : 'gap-1')}>
|
||||||
{directoryTerminalState.tabs.map((tab) => {
|
{directoryTerminalState.tabs.map((tab) => {
|
||||||
const isActive = tab.id === activeTabId;
|
const isActive = tab.id === activeTabId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
? '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)]'
|
: '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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectTab(tab.id)}
|
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}
|
title={tab.label}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -929,8 +1003,9 @@ export const TerminalView: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
'flex items-center justify-center rounded-sm text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||||
!isActive && 'opacity-0 group-hover:opacity-100'
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -938,7 +1013,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
title="Close tab"
|
title="Close tab"
|
||||||
>
|
>
|
||||||
<RiCloseLine size={14} />
|
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -947,10 +1022,13 @@ export const TerminalView: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateTab}
|
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"
|
title="New tab"
|
||||||
>
|
>
|
||||||
<RiAddLine size={16} />
|
<RiAddLine size={isMobile ? 18 : 16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
|
||||||
{shouldRenderViewport ? (
|
{shouldRenderViewport ? (
|
||||||
isMobile ? (
|
<TerminalViewport
|
||||||
<TerminalViewport
|
key={viewportSessionKey}
|
||||||
key={viewportSessionKey}
|
ref={(controller) => {
|
||||||
ref={(controller) => {
|
terminalControllerRef.current = controller;
|
||||||
terminalControllerRef.current = controller;
|
}}
|
||||||
}}
|
sessionKey={viewportSessionKey}
|
||||||
sessionKey={viewportSessionKey}
|
chunks={bufferChunks}
|
||||||
chunks={bufferChunks}
|
onInput={handleViewportInput}
|
||||||
onInput={handleViewportInput}
|
onResize={handleViewportResize}
|
||||||
onResize={handleViewportResize}
|
theme={xtermTheme}
|
||||||
theme={xtermTheme}
|
fontFamily={resolvedFontStack}
|
||||||
fontFamily={resolvedFontStack}
|
fontSize={terminalFontSize}
|
||||||
fontSize={terminalFontSize}
|
enableTouchScroll={hasTouchInput}
|
||||||
enableTouchScroll={hasTouchInput}
|
autoFocus={isTerminalVisible}
|
||||||
autoFocus={isTerminalVisible}
|
keyboardAvoidTargetId={keyboardAvoidTargetId}
|
||||||
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>
|
|
||||||
)
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{connectionError && (
|
{connectionError && (
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ export interface OpenChamberConfig {
|
|||||||
'setup-worktree'?: string[];
|
'setup-worktree'?: string[];
|
||||||
projectNotes?: string;
|
projectNotes?: string;
|
||||||
projectTodos?: OpenChamberProjectTodoItem[];
|
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 {
|
export interface OpenChamberProjectTodoItem {
|
||||||
@@ -75,6 +95,12 @@ export interface OpenChamberProjectNotesTodos {
|
|||||||
|
|
||||||
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000;
|
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000;
|
||||||
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
|
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 => {
|
const normalize = (value: string): string => {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@@ -344,6 +370,105 @@ const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[]
|
|||||||
return sanitized;
|
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: {
|
const sanitizeProjectNotesAndTodos = (value: {
|
||||||
notes?: unknown;
|
notes?: unknown;
|
||||||
todos?: 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.
|
* Substitute variables in a command string.
|
||||||
* Supported variables:
|
* 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;
|
createTab: (directory: string) => string;
|
||||||
setActiveTab: (directory: string, tabId: string) => void;
|
setActiveTab: (directory: string, tabId: string) => void;
|
||||||
|
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||||
|
|
||||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => 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) => {
|
closeTab: async (directory: string, tabId: string) => {
|
||||||
const key = normalizeDirectory(directory);
|
const key = normalizeDirectory(directory);
|
||||||
const entry = get().sessions.get(key);
|
const entry = get().sessions.get(key);
|
||||||
@@ -258,7 +296,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tab = existing.tabs[idx];
|
const tab = existing.tabs[idx];
|
||||||
const shouldResetBuffer = tab.terminalSessionId !== sessionId;
|
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
|
||||||
|
|
||||||
const nextTab: TerminalTab = {
|
const nextTab: TerminalTab = {
|
||||||
...tab,
|
...tab,
|
||||||
|
|||||||
@@ -126,6 +126,7 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Allow system context menu only when explicitly requested via the "More" button */
|
/* Allow system context menu only when explicitly requested via the "More" button */
|
||||||
:root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu {
|
:root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu {
|
||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: default;
|
||||||
|
|||||||
@@ -11528,6 +11528,13 @@ async function main(options = {}) {
|
|||||||
const terminalSessions = new Map();
|
const terminalSessions = new Map();
|
||||||
const MAX_TERMINAL_SESSIONS = 20;
|
const MAX_TERMINAL_SESSIONS = 20;
|
||||||
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
|
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 = {
|
const terminalInputCapabilities = {
|
||||||
input: {
|
input: {
|
||||||
preferred: 'ws',
|
preferred: 'ws',
|
||||||
@@ -11751,7 +11758,7 @@ async function main(options = {}) {
|
|||||||
Math.random().toString(36).substring(2, 15);
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
const envPath = buildAugmentedPath();
|
const envPath = buildAugmentedPath();
|
||||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
|
||||||
|
|
||||||
const pty = await getPtyProvider();
|
const pty = await getPtyProvider();
|
||||||
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
||||||
@@ -11965,7 +11972,7 @@ async function main(options = {}) {
|
|||||||
Math.random().toString(36).substring(2, 15);
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
const envPath = buildAugmentedPath();
|
const envPath = buildAugmentedPath();
|
||||||
const resolvedEnv = { ...process.env, PATH: envPath };
|
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
|
||||||
|
|
||||||
const pty = await getPtyProvider();
|
const pty = await getPtyProvider();
|
||||||
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
||||||
|
|||||||
+11
-15
@@ -13,20 +13,16 @@ declare global {
|
|||||||
|
|
||||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
||||||
|
|
||||||
registerSW({
|
if (import.meta.env.PROD) {
|
||||||
onRegistered(registration: ServiceWorkerRegistration | undefined) {
|
registerSW({
|
||||||
if (!registration) {
|
onRegisterError(error: unknown) {
|
||||||
return;
|
console.warn('[PWA] service worker registration failed:', error);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
// Periodic update check (best-effort)
|
} else if ('serviceWorker' in navigator) {
|
||||||
setInterval(() => {
|
void navigator.serviceWorker.getRegistrations()
|
||||||
void registration.update();
|
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
|
||||||
}, 60 * 60 * 1000);
|
.catch(() => {});
|
||||||
},
|
}
|
||||||
onRegisterError(error: unknown) {
|
|
||||||
console.warn('[PWA] service worker registration failed:', error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import('@openchamber/ui/main');
|
import('@openchamber/ui/main');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { themeStoragePlugin } from '../../vite-theme-plugin';
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
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 reactScanToggle = (process.env.VITE_ENABLE_REACT_SCAN ?? '').toLowerCase();
|
||||||
const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes';
|
const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes';
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export default defineConfig({
|
|||||||
injectionPoint: undefined,
|
injectionPoint: undefined,
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: pwaDevEnabled,
|
||||||
type: 'module',
|
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