feat(preview): embedded dev-server preview pane + dev shutdown controls (#1062)
* feat: embedded preview proxy for local dev servers Add a same-origin server proxy under /api/preview/proxy/:id and matching UI surfaces so local dev servers (Vite, Next, etc.) can be embedded inside OpenChamber. Server (packages/web/server): - New lib/preview/proxy-runtime.js: cookie-gated HTTP+WebSocket proxy to loopback hosts only, with TTL'd targets and SSRF allowlist. - index.js wires the runtime alongside terminal/event-stream. UI (packages/ui): - ContextPanel preview tab with iframe, reload, and open-in-browser. - Inline html code-block preview in MarkdownRenderer. - Terminal auto-detects loopback URLs and offers to open them. - i18n keys across en, es, pt-BR, uk, zh-CN. * perf(preview): cache proxy targets across PreviewPane remounts Module-scoped Map keyed by upstream URL so tab switches and component remounts within the same page session reuse the existing proxy registration instead of POSTing a fresh target each time. In-memory only by design: the server holds the target map in memory and the auth cookie is HttpOnly + scoped to the proxy id, so a stale persisted entry would 404 after a server restart. Entries are evicted on registration error and on a 30s safety margin before TTL expiry. * feat(preview): surface dev-server-down state with retry overlay Iframes don't expose HTTP status to the parent, so when the proxy returns a 502 (upstream dev server is offline) the iframe just renders the raw JSON error body. Probe the proxy URL out-of-band with HEAD (falling back to GET on 404/405) and replace the iframe with a friendly 'Dev server is not responding' overlay + retry button when the upstream is unreachable. Re-probes on reload, on URL change, and on proxy re-registration. * feat(preview): strip frame-busting response headers Many dev servers (Next.js, others) send X-Frame-Options: SAMEORIGIN and/or a CSP with frame-ancestors that block embedding inside the OpenChamber iframe. The proxy is same-origin and already authenticated per-target, so embedding is otherwise safe. - Drop X-Frame-Options outright on proxied responses. - Surgically remove only the frame-ancestors directive from Content-Security-Policy and Content-Security-Policy-Report-Only, preserving every other directive. Drops the header entirely if no directives remain. - Verified end-to-end: upstream sending both headers comes through with X-Frame-Options removed, CSP retaining default-src/script-src but no frame-ancestors, and unrelated headers untouched. * docs(preview): design for remote-host relay agent Design-only doc for the next phase of the embedded preview feature: when OpenChamber runs remotely (cloud/shared/tunnel) and the user's dev server runs on their local machine. Covers architecture (local agent + outbound control WebSocket + server dispatch), pairing flow, wire protocol, security model, failure modes, open questions, and implementation milestones. No code changes. * feat(preview): auto-open preview pane for loopback URLs in chat Detect http(s) loopback URLs in incoming assistant messages and open the preview pane automatically, deduped per (session, url) pair so re-renders or repeated mentions do not steal focus. Add an inline Preview button next to loopback links in chat markdown as a manual fallback when the auto-open was dismissed or the URL appeared in an older message. - url.ts: isLoopbackHttpUrl / extractLoopbackUrls helpers - ChatContainer: module-level dedupe Set + effect on active session tail - MarkdownRendererImpl: optional onPreviewLoopback in main renderer only (SimpleMarkdownRenderer for tool diffs is intentionally untouched) - Reuses existing terminalView.preview.open i18n keys * feat: preview enhancements, dev shutdown, and reliability fixes Add preview start/stop UI in ContextPanel/Header, improve URL detection (Python HTTP server logs, trailing punctuation, IPv6 loopback), fix proxy path filtering to avoid disrupting non-preview WebSockets. Add dev-only /api/system/dev-shutdown endpoint and Header button to terminate local dev processes and orphaned preview servers. Improve terminal cleanup with process group killing, event pipeline reconnect backoff. Update file read APIs with optional flag and cache control. Add /api/system/free-port endpoint, detectDevServer.ts utility, and preview/shutdown i18n strings for 5 languages. * fix: harden preview support * fix: keep terminal toolbar interactive * fix: keep expanded terminal below header * fix: keep preview iframe under proxy path * fix: respect project action preview urls * fix: rewrite preview asset urls * feat: capture preview console logs * feat: annotate preview elements * feat: attach preview annotation screenshots * fix: improve proxied preview hmr * feat: refine preview action UX * fix: address preview review feedback * fix: show auto-discover preview wait state --------- Co-authored-by: William Biggers <will@Williams-MacBook-Pro.local> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
William Biggers
Bohdan Triapitsyn
parent
67d05a23fc
commit
bd9a91335c
@@ -0,0 +1,326 @@
|
||||
# Preview — Remote-host relay (design)
|
||||
|
||||
Status: design only, no implementation.
|
||||
Owner: TBD.
|
||||
Audience: contributors planning the next phase of the embedded preview feature.
|
||||
|
||||
## Problem
|
||||
|
||||
The current preview implementation (`packages/web/server/lib/preview/proxy-runtime.js`,
|
||||
`packages/ui/src/components/layout/ContextPanel.tsx`) terminates inside the
|
||||
OpenChamber server process and forwards requests to a **loopback** target
|
||||
(`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`). It works for these topologies:
|
||||
|
||||
| Topology | Works today? |
|
||||
| ------------------------------------------------------------------------ | ------------ |
|
||||
| Web UI in browser, OpenChamber server on same host as dev server | yes |
|
||||
| Electron desktop, dev server on same host | yes |
|
||||
| VS Code extension, dev server on same host | yes |
|
||||
| Mobile/tablet hitting OpenChamber over LAN, dev server on host | yes |
|
||||
| **Remote OpenChamber** (cloud / shared / tunneled), dev server on user's local machine | **no** |
|
||||
|
||||
The blocked case is real: a user runs `openchamber serve` on a remote box (or a
|
||||
hosted OpenChamber instance) but their dev server (`vite`, `next dev`, etc.)
|
||||
runs on their laptop. The proxy correctly refuses to talk to non-loopback
|
||||
targets — that is a deliberate SSRF gate, not a bug. We need a separate path
|
||||
that tunnels traffic from the remote OpenChamber back to the user's laptop
|
||||
without weakening that gate.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing the existing loopback proxy. The local-loopback path is the common
|
||||
case and stays unchanged.
|
||||
- Acting as a generic public ingress for arbitrary local services. We only
|
||||
expose dev servers selected through the preview UI, scoped to the active
|
||||
user's session.
|
||||
- Providing a hosted relay service. The relay is something the user runs;
|
||||
OpenChamber provides the agent + the server endpoints.
|
||||
|
||||
## Constraints (carried forward from the loopback proxy)
|
||||
|
||||
- Same-origin in the browser. The iframe must load from the OpenChamber
|
||||
origin so HTTPS, cookies, and CSP behave predictably.
|
||||
- Per-target cookie auth. A target id must not be guessable, and the cookie
|
||||
must be HttpOnly + scoped to that target's path.
|
||||
- WebSocket upgrade support (HMR is a hard requirement; without it the
|
||||
feature is uninteresting).
|
||||
- Strip frame-busting headers on the response.
|
||||
- Strip OpenChamber credentials before forwarding to the dev server.
|
||||
- Survive partial failure cleanly: if the agent disconnects, the iframe
|
||||
should land on the existing "dev server is not responding" overlay, not a
|
||||
zombie hang.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three components, in order of where they run.
|
||||
|
||||
### 1. Local agent (runs on the user's laptop)
|
||||
|
||||
A small process the user starts on the same machine as the dev server. Two
|
||||
shipping options:
|
||||
|
||||
- A subcommand of the existing CLI: `openchamber preview-agent`.
|
||||
- A standalone single-binary build for users who do not have the full UI
|
||||
installed locally.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Open exactly one outbound, authenticated WebSocket to the remote
|
||||
OpenChamber server (`wss://<host>/api/preview/agent`). Outbound-only — no
|
||||
inbound port on the user's machine, so it works behind NAT, VPN,
|
||||
corporate firewall, etc.
|
||||
- Authenticate with a short-lived enrollment token issued by the remote
|
||||
OpenChamber server (see "Pairing flow").
|
||||
- Advertise the set of dev servers the user has authorised. Scope is
|
||||
loopback-only on the agent side (same allowlist as the existing proxy:
|
||||
`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`). The agent never proxies to
|
||||
arbitrary hosts on the user's network.
|
||||
- Multiplex per-request streams over the single control WebSocket
|
||||
(frame protocol below). Each browser request becomes one logical stream.
|
||||
- Forward HTTP and upgraded WebSocket connections to the local dev server.
|
||||
- Send authoritative `agent-disconnected` notifications so the server can
|
||||
evict targets immediately rather than waiting for TTL.
|
||||
|
||||
Deliberately out of scope for the agent:
|
||||
|
||||
- TLS termination. The agent only talks to loopback over plain HTTP; the
|
||||
outbound link to OpenChamber is TLS via the server's existing cert.
|
||||
- Anything that mutates the user's filesystem.
|
||||
- Acting as a general SOCKS/HTTP proxy. It is dev-server-scoped.
|
||||
|
||||
### 2. Remote OpenChamber server (extends `proxy-runtime.js`)
|
||||
|
||||
Adds two new surfaces alongside the existing loopback proxy:
|
||||
|
||||
- `GET /api/preview/agent` (WebSocket): the single control channel an agent
|
||||
connects to after enrollment. Authenticated by the enrollment token + the
|
||||
user's UI session.
|
||||
- `POST /api/preview/targets/remote`: same shape as the existing
|
||||
`POST /api/preview/targets`, but the URL is interpreted **relative to a
|
||||
connected agent**. The body becomes
|
||||
`{ agentId, url, ttlMs? }` (or the existing endpoint accepts an optional
|
||||
`agentId` and dispatches to the right path). The response keeps the same
|
||||
contract: `{ id, proxyBasePath, expiresAt }`. The browser does not learn
|
||||
it is talking to a remote agent — that is a server-side detail.
|
||||
|
||||
The existing `/api/preview/proxy/:id/*` route is reused unchanged from the
|
||||
browser's perspective. Internally it now dispatches based on the registered
|
||||
target type:
|
||||
|
||||
- `kind: 'loopback'` (existing) → `http-proxy-middleware` to a local origin.
|
||||
- `kind: 'agent'` (new) → encode the request into a frame, push it onto the
|
||||
matching agent's WebSocket, await the response frames, stream them back
|
||||
to the browser.
|
||||
|
||||
This dispatch boundary is the only invasive change to the existing runtime.
|
||||
The factory stays `createPreviewProxyRuntime`; the agent registry, frame
|
||||
codec, and response streaming live in a sibling module
|
||||
(`packages/web/server/lib/preview/agent-runtime.js`) so the loopback path
|
||||
remains readable and individually testable.
|
||||
|
||||
### 3. Browser (UI layer)
|
||||
|
||||
Almost no change. `PreviewPane` already POSTs to `/api/preview/targets` and
|
||||
loads the iframe at the returned `proxyBasePath`. The remote case adds:
|
||||
|
||||
- A small "no agent connected" empty state when the user's profile has no
|
||||
active agent but tries to preview a non-public URL. Gives them the exact
|
||||
command to run and a one-click copy of the enrollment token.
|
||||
- The existing 502 / dev-server-down overlay handles agent disconnects too
|
||||
— the proxy returns 502 if the agent vanishes mid-request.
|
||||
|
||||
## Pairing / enrollment flow
|
||||
|
||||
The agent must prove it is acting on behalf of a specific UI user, and the
|
||||
server must be able to revoke that proof.
|
||||
|
||||
1. User opens Settings → Preview → "Connect a local dev-server agent".
|
||||
2. Server mints a short-lived (5 min) enrollment token bound to the user's
|
||||
UI session id, with a single allowed scope: `preview-agent.connect`. UI
|
||||
shows the command:
|
||||
```
|
||||
openchamber preview-agent --server https://<host> --token <enrollment-token>
|
||||
```
|
||||
3. Agent posts the enrollment token to `POST /api/preview/agent/enroll` and
|
||||
receives a long-lived `agentId` + `agentSecret`. Stored in the agent's
|
||||
config dir (`$XDG_CONFIG_HOME/openchamber/agent.json` or platform
|
||||
equivalent).
|
||||
4. Agent opens the control WebSocket, authenticating with `agentId` +
|
||||
`agentSecret`. The server verifies and registers the agent against the
|
||||
owning user.
|
||||
5. Agent sends an initial `hello` frame with: agent version, OS, hostname
|
||||
hint (display only — never used for routing), and a list of dev-server
|
||||
URLs the user has explicitly approved on the agent side.
|
||||
|
||||
Revocation:
|
||||
|
||||
- User can revoke an agent from Settings; the server invalidates the
|
||||
`agentSecret` and closes any open WebSocket.
|
||||
- The agent honours `disconnect` frames from the server with a clean
|
||||
shutdown.
|
||||
- Enrollment tokens are single-use and expire after 5 min.
|
||||
|
||||
## Wire protocol (control WebSocket)
|
||||
|
||||
Binary frames, little-endian, one frame = one logical operation. JSON metadata
|
||||
header followed by an opaque body. Designed to be implementable in Node and
|
||||
Bun without exotic deps.
|
||||
|
||||
```
|
||||
+--------+--------+--------+----------------------+----------------------+
|
||||
| u8 ver | u8 op | u32 len| metadata (JSON, len) | body (remaining) |
|
||||
+--------+--------+--------+----------------------+----------------------+
|
||||
```
|
||||
|
||||
Operations:
|
||||
|
||||
| op | name | direction | metadata | body |
|
||||
| ---- | ----------------- | -------------- | ------------------------------------------------------------------- | ----------------------------------- |
|
||||
| 0x01 | hello | agent → server | `{ agentVersion, hostnameHint, allowedTargets: [{origin}] }` | empty |
|
||||
| 0x02 | hello-ack | server → agent | `{ ok, serverVersion }` or `{ ok: false, reason }` | empty |
|
||||
| 0x10 | http-request | server → agent | `{ streamId, method, path, headers, originHint }` | request body bytes |
|
||||
| 0x11 | http-response-head| agent → server | `{ streamId, status, headers }` | empty |
|
||||
| 0x12 | http-response-data| agent → server | `{ streamId, fin: bool }` | response body chunk |
|
||||
| 0x13 | http-error | agent → server | `{ streamId, code, message }` | empty |
|
||||
| 0x20 | ws-open | server → agent | `{ streamId, path, headers, subprotocols }` | empty |
|
||||
| 0x21 | ws-open-ack | agent → server | `{ streamId, ok, status?, subprotocol? }` | empty |
|
||||
| 0x22 | ws-frame | both | `{ streamId, opcode: 'text'|'binary', fin: bool }` | frame payload |
|
||||
| 0x23 | ws-close | both | `{ streamId, code?, reason? }` | empty |
|
||||
| 0x30 | cancel | server → agent | `{ streamId }` | empty |
|
||||
| 0xFE | ping | both | `{ ts }` | empty |
|
||||
| 0xFF | disconnect | server → agent | `{ reason }` | empty |
|
||||
|
||||
Notes:
|
||||
|
||||
- `streamId` is server-assigned for `http-request` and `ws-open`. It scopes
|
||||
ordering and back-pressure per logical request.
|
||||
- Body chunks for HTTP responses are streamed (`fin: false` until the last
|
||||
chunk). The server proxies them to the browser without buffering, so
|
||||
large downloads do not balloon memory on either side.
|
||||
- The `originHint` lets the agent log which approved target a request was
|
||||
routed to; routing itself is determined by the registered target's
|
||||
`agentId` + origin, not by anything the browser sends.
|
||||
- Back-pressure: if the server's downstream socket is paused, it stops
|
||||
reading from the agent's WebSocket. WebSocket flow control then applies
|
||||
end-to-end. We do not implement an additional credit scheme until
|
||||
measurement shows we need one.
|
||||
|
||||
## Security model
|
||||
|
||||
Every guarantee the loopback proxy gives must hold here too. Checked
|
||||
against the same threat model:
|
||||
|
||||
- **Server-side SSRF**: target URLs are still validated against the loopback
|
||||
allowlist — but on the agent, not the server. The server never makes a
|
||||
network call on behalf of a target.
|
||||
- **Cross-user target access**: a target id is owned by the user that
|
||||
registered it. Cookie + path scope unchanged.
|
||||
- **Cross-agent leakage**: a target id is also bound to the specific
|
||||
`agentId` it was registered against. Even if two users somehow share a
|
||||
target id (they cannot — ids are 128-bit random), dispatch only reaches
|
||||
the agent the target was bound to.
|
||||
- **Agent impersonation**: `agentSecret` is per-agent, stored only on the
|
||||
user's machine, transported only over TLS during enrollment + connect.
|
||||
Revocable from Settings.
|
||||
- **Frame-busting headers**: stripped server-side after the agent returns
|
||||
the response, identical to the loopback path. Same code path
|
||||
(`stripFrameBustingHeaders`) — keep it as a single point of truth.
|
||||
- **Dev-server credentials**: the agent strips `cookie`, `authorization`,
|
||||
and `x-openchamber-ui-session` before forwarding to the local dev
|
||||
server, mirroring the existing `proxyReq` handler.
|
||||
- **Public-internet exposure**: no inbound port opens on the user's
|
||||
machine; no egress to non-loopback addresses; the agent process refuses
|
||||
to start with `0.0.0.0` upstream targets that resolve off-loopback.
|
||||
- **Connection pinning**: when the agent's WebSocket disconnects, all of
|
||||
its targets are evicted immediately and any in-flight streams are
|
||||
aborted with 502. The cached entry on the browser side (see
|
||||
`previewProxyTargetCache` in `ContextPanel.tsx`) will then re-register
|
||||
on the next attempt and surface the "no agent connected" empty state.
|
||||
|
||||
Out-of-scope hardening to revisit later:
|
||||
|
||||
- mTLS for the agent ↔ server link (current proposal: TLS + agentSecret;
|
||||
mTLS is a future option for self-hosters who want it).
|
||||
- Audit logging of every proxied request (today the loopback path doesn't
|
||||
do this; the remote path should not become an exception without a UX
|
||||
for inspecting the log).
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Agent never connected | `POST /api/preview/targets/remote` returns 409 with `{ error: 'No agent connected' }`. UI shows empty state. |
|
||||
| Agent disconnected mid-request | Server cancels the stream, returns 502 to the browser, evicts the target. Existing overlay handles it. |
|
||||
| Dev server down on user's laptop | Agent forwards the connection refusal as `http-error`; server emits 502. Existing overlay handles it. |
|
||||
| Slow agent / dev server | Streamed response keeps flowing; no buffering on the server. WebSocket flow control gates the data rate. |
|
||||
| Server restarted | Agent reconnects with stored `agentSecret`. Browser-side cache 404s on next request and re-registers. |
|
||||
| Enrollment token expired | `POST /api/preview/agent/enroll` returns 401 with a clear error; UI prompts to mint a new one. |
|
||||
| Two agents registered for same user | Allowed. The browser-side flow always picks the most recently active agent for a given upstream URL. |
|
||||
|
||||
## Open questions
|
||||
|
||||
These need a decision before implementation, not before the doc lands.
|
||||
|
||||
1. **CLI surface.** Is `openchamber preview-agent` the right verb, or should
|
||||
it live under `openchamber agent preview`? Bias: the former; only one
|
||||
agent today, and we can rename without breaking anything if we ever ship
|
||||
a second.
|
||||
2. **Multi-agent UX.** When a user has two agents online (laptop + desktop)
|
||||
and registers a `localhost:3000` preview, which one wins? Most-recent
|
||||
activity is a sensible default but we should also let the user pin a
|
||||
target to an agent.
|
||||
3. **Browser-side detection of remote vs loopback.** Today the UI has no
|
||||
reason to know. If the empty state needs the user's enrolled agents,
|
||||
that becomes a new `GET /api/preview/agents` endpoint. Acceptable.
|
||||
4. **Storage of `agentSecret`.** Plain file under the agent config dir is
|
||||
simplest. OS keychain integration is nicer but a much larger surface.
|
||||
Bias: file first, keychain later.
|
||||
5. **Frame protocol vs. full HTTP/2 / gRPC.** The custom frame protocol is
|
||||
maybe 200 lines in each runtime. gRPC would handle streaming and back
|
||||
pressure for us but adds a heavy dep. Bias: custom frames; revisit only
|
||||
if we hit a back-pressure or multiplexing bug we cannot solve cleanly.
|
||||
6. **Compression.** The current loopback path forces `accept-encoding:
|
||||
identity` to keep the proxy simple. The remote path probably wants
|
||||
gzip/br between the agent and the server to save bandwidth on slow
|
||||
links — but the dev server may not be configured for it. Decide once we
|
||||
measure.
|
||||
|
||||
## Implementation milestones
|
||||
|
||||
Each milestone is independently shippable and reviewable. Numbers are
|
||||
sequence, not effort.
|
||||
|
||||
1. Agent registry + enrollment endpoints on the server. No proxying yet.
|
||||
Settings UI to mint and revoke enrollment tokens.
|
||||
2. Standalone agent that connects, says hello, and stays connected with
|
||||
ping/pong. No proxying yet. Validates the auth + reconnect story.
|
||||
3. HTTP-only proxying through the agent (`http-request` /
|
||||
`http-response-*`). Browser can register a remote target and load
|
||||
static pages. No HMR yet.
|
||||
4. WebSocket proxying through the agent (`ws-open` / `ws-frame` /
|
||||
`ws-close`). HMR works.
|
||||
5. Failure-mode polish: 502 on disconnect, target eviction, browser-side
|
||||
empty state, "agent connected" indicator in Settings.
|
||||
6. Documentation + tutorial for the remote-host scenario; update
|
||||
`docs/REVERSE_PROXY.md` cross-link.
|
||||
|
||||
## Why not …?
|
||||
|
||||
- **A reverse SSH tunnel from the agent.** Works but requires SSH server
|
||||
on the OpenChamber host, exposes a port, and breaks the same-origin
|
||||
guarantee unless we also reverse-proxy that port through the
|
||||
OpenChamber HTTP server. The control-WebSocket design avoids all of
|
||||
that and keeps a single TLS endpoint.
|
||||
- **Cloudflare/ngrok-style hosted relay.** Would work but turns
|
||||
OpenChamber into a service that depends on a third party (or on us
|
||||
hosting a relay). The agent design lets users run entirely
|
||||
self-hosted.
|
||||
- **WebRTC data channels.** Lower latency in theory, much harder to debug
|
||||
and to reason about behind corporate NATs. Not worth the complexity
|
||||
for HTTP + WS forwarding.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Loopback runtime: `packages/web/server/lib/preview/proxy-runtime.js`
|
||||
- Browser PreviewPane + cache: `packages/ui/src/components/layout/ContextPanel.tsx`
|
||||
- Reverse-proxy deployment notes: `docs/REVERSE_PROXY.md`
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"author": "Bohdan Triapitsyn",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"",
|
||||
"dev": "OPENCHAMBER_DEV_SHUTDOWN=true concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"",
|
||||
"build": "bun run --filter '*' build",
|
||||
"build:web": "bun run --cwd packages/web build",
|
||||
"build:ui": "bun run --cwd packages/ui build",
|
||||
|
||||
@@ -104,6 +104,7 @@ const MIN_WINDOW_WIDTH = 800;
|
||||
const MIN_WINDOW_HEIGHT = 520;
|
||||
const MIN_RESTORE_WINDOW_WIDTH = 900;
|
||||
const MIN_RESTORE_WINDOW_HEIGHT = 560;
|
||||
const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000;
|
||||
const LOCAL_HOST_ID = 'local';
|
||||
const ENV_OVERRIDE_HOST_ID = '__env';
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md';
|
||||
@@ -1648,6 +1649,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
case 'desktop_get_app_version':
|
||||
return APP_VERSION;
|
||||
|
||||
case 'desktop_capture_page_rect': {
|
||||
if (!browserWindow || browserWindow.isDestroyed()) {
|
||||
throw new Error('Window is not available');
|
||||
}
|
||||
|
||||
const bounds = browserWindow.getContentBounds();
|
||||
const x = Number.isFinite(args.x) ? Math.max(0, Math.floor(args.x)) : 0;
|
||||
const y = Number.isFinite(args.y) ? Math.max(0, Math.floor(args.y)) : 0;
|
||||
const width = Number.isFinite(args.width) ? Math.max(1, Math.floor(args.width)) : 1;
|
||||
const height = Number.isFinite(args.height) ? Math.max(1, Math.floor(args.height)) : 1;
|
||||
const clampedX = Math.min(x, Math.max(0, bounds.width - 1));
|
||||
const clampedY = Math.min(y, Math.max(0, bounds.height - 1));
|
||||
const rect = {
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: Math.min(width, Math.max(1, bounds.width - clampedX)),
|
||||
height: Math.min(height, Math.max(1, bounds.height - clampedY)),
|
||||
};
|
||||
if (rect.width * rect.height > MAX_CAPTURE_PAGE_RECT_AREA) {
|
||||
throw new Error('Capture area is too large');
|
||||
}
|
||||
|
||||
const image = await browserWindow.webContents.capturePage(rect);
|
||||
const buffer = image.toJPEG(82);
|
||||
return {
|
||||
mime: 'image/jpeg',
|
||||
base64: buffer.toString('base64'),
|
||||
width: image.getSize().width,
|
||||
height: image.getSize().height,
|
||||
};
|
||||
}
|
||||
|
||||
case 'desktop_save_markdown_file': {
|
||||
const defaultPath = typeof args.defaultFileName === 'string' ? args.defaultFileName.trim() : '';
|
||||
if (!defaultPath) {
|
||||
@@ -2247,6 +2280,7 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([
|
||||
'desktop_start_window_drag',
|
||||
'desktop_get_app_version',
|
||||
'desktop_get_lan_address',
|
||||
'desktop_capture_page_rect',
|
||||
]);
|
||||
|
||||
ipcMain.handle('openchamber:invoke', async (event, command, args) => {
|
||||
|
||||
@@ -1021,8 +1021,38 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
)
|
||||
);
|
||||
const draftSourceKey = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}`;
|
||||
},
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
)
|
||||
);
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === source) {
|
||||
removeInlineCommentDraft(sessionKey, draft.id);
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
|
||||
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
@@ -3270,19 +3300,61 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
onEditMessage={handleQueuedMessageEdit}
|
||||
/>
|
||||
{hasDrafts && (
|
||||
<div className="pb-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>
|
||||
{draftCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{reviewCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{reviewCount}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{previewConsoleCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.devServerLogs')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewConsoleCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
onClick={() => removePreviewDrafts('preview-console')}
|
||||
aria-label={t('chat.chatInput.devServerLogsRemove')}
|
||||
title={t('chat.chatInput.devServerLogsRemove')}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAnnotationCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.previewAnnotations')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewAnnotationCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
onClick={() => removePreviewDrafts('preview-annotation')}
|
||||
aria-label={t('chat.chatInput.previewContextRemove')}
|
||||
title={t('chat.chatInput.previewContextRemove')}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ import remend from 'remend';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine, RiEyeLine, RiCodeLine } from '@remixicon/react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
@@ -694,6 +694,26 @@ const CODE_SHARED_STYLE: React.CSSProperties = {
|
||||
lineHeight: 'var(--markdown-code-block-line-height)',
|
||||
};
|
||||
|
||||
const downloadTextFile = (content: string, filename: string, mimeType: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Best-effort; callers can optionally toast.
|
||||
}
|
||||
};
|
||||
|
||||
const MarkdownCodeBlock: React.FC<{
|
||||
code: string;
|
||||
language: string;
|
||||
@@ -701,9 +721,18 @@ const MarkdownCodeBlock: React.FC<{
|
||||
}> = ({ code, language, syntaxTheme }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [highlight, setHighlight] = React.useState(true);
|
||||
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
|
||||
const prevCodeRef = React.useRef<string>(code);
|
||||
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const canPreview = language === 'html' || language === 'htm';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!canPreview && viewMode !== 'code') {
|
||||
setViewMode('code');
|
||||
}
|
||||
}, [canPreview, viewMode]);
|
||||
|
||||
// Defer Prism highlighting while code is actively streaming.
|
||||
// Initial mount renders highlighted immediately (plays nice with finalized blocks).
|
||||
React.useEffect(() => {
|
||||
@@ -732,46 +761,96 @@ const MarkdownCodeBlock: React.FC<{
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
const handleDownload = React.useCallback(() => {
|
||||
if (!canPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSuffix = Date.now().toString(36);
|
||||
downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8');
|
||||
}, [canPreview, code]);
|
||||
|
||||
return (
|
||||
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
|
||||
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
|
||||
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="flex items-center gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
|
||||
aria-pressed={viewMode === 'preview'}
|
||||
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
|
||||
>
|
||||
{viewMode === 'preview' ? <RiCodeLine className="size-3.5" /> : <RiEyeLine className="size-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Download HTML"
|
||||
aria-label="Download HTML"
|
||||
>
|
||||
<RiDownloadLine className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleCopy(); }}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={copied ? 'Copied' : 'Copy code'}
|
||||
aria-label={copied ? 'Copied' : 'Copy code'}
|
||||
>
|
||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
{highlight ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxTheme}
|
||||
customStyle={CODE_SHARED_STYLE}
|
||||
codeTagProps={{ style: CODE_SHARED_STYLE }}
|
||||
PreTag="pre"
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<pre style={CODE_SHARED_STYLE}>
|
||||
<code style={CODE_SHARED_STYLE}>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{canPreview && viewMode === 'preview' ? (
|
||||
<div className="h-[320px] md:h-[420px] bg-background">
|
||||
<iframe
|
||||
srcDoc={code}
|
||||
title="HTML preview"
|
||||
className="h-full w-full border-0"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2.5">
|
||||
{highlight ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxTheme}
|
||||
customStyle={CODE_SHARED_STYLE}
|
||||
codeTagProps={{ style: CODE_SHARED_STYLE }}
|
||||
PreTag="pre"
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<pre style={CODE_SHARED_STYLE}>
|
||||
<code style={CODE_SHARED_STYLE}>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const buildMarkdownComponents = ({
|
||||
syntaxTheme,
|
||||
onPreviewLoopback,
|
||||
previewLabel,
|
||||
previewTitle,
|
||||
}: {
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
previewLabel?: string;
|
||||
previewTitle?: string;
|
||||
}): Components => ({
|
||||
table({ children, ...props }) {
|
||||
return <TableWrapper className={props.className}>{children}</TableWrapper>;
|
||||
@@ -846,15 +925,36 @@ const buildMarkdownComponents = ({
|
||||
);
|
||||
},
|
||||
a({ href, children, ...props }) {
|
||||
const targetHref = href ?? '';
|
||||
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
target={isExternalHttpUrl(href ?? '') ? '_blank' : undefined}
|
||||
rel={isExternalHttpUrl(href ?? '') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<>
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
target={isExternalHttpUrl(targetHref) ? '_blank' : undefined}
|
||||
rel={isExternalHttpUrl(targetHref) ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{isLoopback && onPreviewLoopback ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onPreviewLoopback(targetHref);
|
||||
}}
|
||||
className="ml-1 inline-flex h-5 items-center gap-0.5 rounded border border-[var(--border)] bg-[var(--surface-background)] px-1.5 align-middle text-[11px] leading-none text-[var(--muted-foreground)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--foreground)]"
|
||||
aria-label={previewTitle ?? previewLabel ?? 'Open preview pane'}
|
||||
title={previewTitle ?? previewLabel ?? 'Open preview pane'}
|
||||
data-loopback-preview-trigger="true"
|
||||
>
|
||||
<RiEyeLine className="size-3" aria-hidden="true" />
|
||||
<span className="font-medium">{previewLabel ?? 'Preview'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1436,8 +1536,24 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
const { t } = useI18n();
|
||||
const handlePreviewLoopback = React.useCallback((url: string) => {
|
||||
if (!effectiveDirectory) return;
|
||||
openContextPreview(effectiveDirectory, url);
|
||||
}, [effectiveDirectory, openContextPreview]);
|
||||
const previewLabel = t('terminalView.preview.open');
|
||||
const previewTitle = t('terminalView.preview.openTitle');
|
||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
|
||||
const markdownComponents = React.useMemo(
|
||||
() => buildMarkdownComponents({
|
||||
syntaxTheme,
|
||||
onPreviewLoopback: effectiveDirectory ? handlePreviewLoopback : undefined,
|
||||
previewLabel,
|
||||
previewTitle,
|
||||
}),
|
||||
[syntaxTheme, effectiveDirectory, handlePreviewLoopback, previewLabel, previewTitle],
|
||||
);
|
||||
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
const markdownBlocks = useStableMarkdownBlocks(content, isStreaming && !disableStreamAnimation, componentKey);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine } from '@remixicon/react';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine, RiGlobalLine } from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { extractLoopbackUrls } from '@/lib/url';
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
|
||||
@@ -960,6 +961,36 @@ const AssistantMessageBody = React.memo(({
|
||||
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
|
||||
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
|
||||
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
const messagePreviewUrl = React.useMemo(() => {
|
||||
for (const part of assistantTextParts) {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const url = extractLoopbackUrls(text)[0];
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
|
||||
}
|
||||
for (const part of toolParts) {
|
||||
const state = (part as unknown as { state?: unknown }).state as Record<string, unknown> | undefined;
|
||||
const output = state && typeof state.output === 'string' ? state.output : null;
|
||||
if (!output) {
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const url = extractLoopbackUrls(output.replace(/\x1b\[[0-9;]*m/g, ''))[0];
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
|
||||
}
|
||||
return null;
|
||||
}, [assistantTextParts, toolParts]);
|
||||
|
||||
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
||||
@@ -1658,9 +1689,35 @@ const AssistantMessageBody = React.memo(({
|
||||
}, [messageCompletedAt, messageCreatedAt]);
|
||||
|
||||
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
|
||||
const canOpenMessagePreview = !isMobile && !isVSCodeRuntime();
|
||||
|
||||
const finalTurnActionButtons = (
|
||||
<>
|
||||
{canOpenMessagePreview && messagePreviewUrl ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.openPreviewAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
const directory = effectiveDirectory
|
||||
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : null);
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
openContextPreview(directory, messagePreviewUrl);
|
||||
}}
|
||||
>
|
||||
<RiGlobalLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isVSCodeRuntime() ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -20,13 +19,11 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
|
||||
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
|
||||
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
|
||||
const [fullscreenHeight, setFullscreenHeight] = React.useState<number | null>(null);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const dockRef = React.useRef<HTMLElement | null>(null);
|
||||
const startYRef = React.useRef(0);
|
||||
const startHeightRef = React.useRef(bottomTerminalHeight || 300);
|
||||
const previousHeightRef = React.useRef(bottomTerminalHeight || 300);
|
||||
|
||||
const standardHeight = React.useMemo(
|
||||
() => Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)),
|
||||
@@ -116,30 +113,18 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
setBottomTerminalExpanded(false);
|
||||
const restoreHeight = Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, previousHeightRef.current));
|
||||
setBottomTerminalHeight(restoreHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
previousHeightRef.current = standardHeight;
|
||||
setBottomTerminalExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={dockRef}
|
||||
className={cn(
|
||||
'flex overflow-hidden border-t border-border bg-sidebar',
|
||||
isFullscreen ? 'absolute inset-0 z-40' : 'relative',
|
||||
isFullscreen ? 'absolute inset-x-0 bottom-0 z-40' : 'relative',
|
||||
isResizing ? 'transition-none' : 'transition-[height] duration-300 ease-in-out',
|
||||
!isOpen && 'border-t-0'
|
||||
)}
|
||||
style={isFullscreen ? undefined : {
|
||||
style={isFullscreen ? {
|
||||
top: 'var(--oc-header-height, 48px)',
|
||||
} : {
|
||||
height: `${appliedHeight}px`,
|
||||
minHeight: `${appliedHeight}px`,
|
||||
maxHeight: `${appliedHeight}px`,
|
||||
@@ -159,29 +144,6 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-2 top-2 z-30 inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
title={isFullscreen ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
|
||||
aria-label={isFullscreen ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
|
||||
>
|
||||
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBottomTerminalOpen(false)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
title={t('terminalView.bottomDock.closeTitle')}
|
||||
aria-label={t('terminalView.bottomDock.closeAria')}
|
||||
>
|
||||
<RiCloseLine className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,8 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||
import { forceKillTerminal } from '@/lib/terminalApi';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
||||
import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
|
||||
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
@@ -259,6 +261,9 @@ type DesktopServicesMenuProps = {
|
||||
expandedFamilies: Record<string, string[]>;
|
||||
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
|
||||
shortcutLabel: (actionId: string) => string;
|
||||
showDevShutdown: boolean;
|
||||
isDevShutdownInFlight: boolean;
|
||||
onDevShutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||
@@ -285,6 +290,9 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||
expandedFamilies,
|
||||
toggleFamilyExpanded,
|
||||
shortcutLabel,
|
||||
showDevShutdown,
|
||||
isDevShutdownInFlight,
|
||||
onDevShutdown,
|
||||
}: DesktopServicesMenuProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -521,6 +529,22 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDevShutdown ? (
|
||||
<>
|
||||
<div className="mx-4 my-2 border-t border-[var(--interactive-border)]" />
|
||||
<div className="px-2 pb-2">
|
||||
<DropdownMenuItem
|
||||
disabled={isDevShutdownInFlight}
|
||||
onSelect={() => {
|
||||
void onDevShutdown();
|
||||
}}
|
||||
>
|
||||
{t('header.services.shutdownDev')}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -584,8 +608,8 @@ const normalize = (value: string): string => {
|
||||
const getActiveContextMode = (panelState: {
|
||||
isOpen: boolean;
|
||||
activeTabId: string | null;
|
||||
tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' }>;
|
||||
} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | null => {
|
||||
tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' }>;
|
||||
} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | null => {
|
||||
if (!panelState?.isOpen || !Array.isArray(panelState.tabs) || panelState.tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -646,6 +670,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const [isDevShutdownInFlight, setIsDevShutdownInFlight] = React.useState(false);
|
||||
|
||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
@@ -1501,6 +1526,63 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}));
|
||||
}, [servicesTabs]);
|
||||
|
||||
const showDevShutdown = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (isDesktopApp) return false;
|
||||
if (isVSCode) return false;
|
||||
const host = window.location.hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}, [isDesktopApp, isVSCode]);
|
||||
|
||||
const handleDevShutdown = React.useCallback(async () => {
|
||||
if (isDevShutdownInFlight) return;
|
||||
setIsDevShutdownInFlight(true);
|
||||
setIsDesktopServicesOpen(false);
|
||||
|
||||
const previewUrls: string[] = [];
|
||||
let shutdownRequested = false;
|
||||
try {
|
||||
try {
|
||||
for (const [, dirState] of useTerminalStore.getState().sessions.entries()) {
|
||||
for (const tab of dirState.tabs) {
|
||||
if (tab.previewUrl) {
|
||||
previewUrls.push(tab.previewUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure preview/dev terminals don't linger.
|
||||
await forceKillTerminal({});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const devRes = await fetch('/api/system/dev-shutdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ previewUrls }),
|
||||
});
|
||||
if (devRes.ok) {
|
||||
shutdownRequested = true;
|
||||
} else {
|
||||
const shutdownRes = await fetch('/api/system/shutdown', { method: 'POST' });
|
||||
shutdownRequested = shutdownRes.ok;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} finally {
|
||||
if (!shutdownRequested) {
|
||||
setIsDevShutdownInFlight(false);
|
||||
}
|
||||
}
|
||||
}, [isDevShutdownInFlight, setIsDesktopServicesOpen]);
|
||||
|
||||
const quotaDisplayTabs = React.useMemo(() => {
|
||||
return [
|
||||
{ value: 'usage' as const, label: t('header.services.used') },
|
||||
@@ -1685,6 +1767,9 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
expandedFamilies={expandedFamilies}
|
||||
toggleFamilyExpanded={toggleFamilyExpanded}
|
||||
shortcutLabel={shortcutLabel}
|
||||
showDevShutdown={showDevShutdown}
|
||||
isDevShutdownInFlight={isDevShutdownInFlight}
|
||||
onDevShutdown={handleDevShutdown}
|
||||
/>
|
||||
<HeaderIconActionButton
|
||||
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
|
||||
|
||||
@@ -2,8 +2,10 @@ import React from 'react';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiGlobalLine,
|
||||
RiLoader4Line,
|
||||
RiPlayLine,
|
||||
RiSearchLine,
|
||||
RiStopLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -35,20 +38,14 @@ import {
|
||||
resolveProjectActionDesktopForwardUrl,
|
||||
toProjectActionRunKey,
|
||||
} from '@/lib/projectActions';
|
||||
|
||||
type RunningEntry = {
|
||||
key: string;
|
||||
directory: string;
|
||||
actionId: string;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
status: 'running' | 'stopping';
|
||||
};
|
||||
import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer';
|
||||
import { connectTerminalStream } from '@/lib/terminalApi';
|
||||
|
||||
type UrlWatchEntry = {
|
||||
lastSeenChunkId: number | null;
|
||||
openedUrl: boolean;
|
||||
tail: string;
|
||||
openInPreview: boolean;
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => {
|
||||
@@ -68,6 +65,8 @@ interface ProjectActionsButtonProps {
|
||||
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 AUTO_DISCOVER_ACTION_ID = '__openchamber_auto_discover_preview__';
|
||||
const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
const stripControlChars = (value: string): string => {
|
||||
let next = '';
|
||||
@@ -194,20 +193,28 @@ export const ProjectActionsButton = ({
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
const terminalSessions = useTerminalStore((state) => state.sessions);
|
||||
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
|
||||
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
|
||||
const setTabIconKey = useTerminalStore((state) => state.setTabIconKey);
|
||||
const setActiveTab = useTerminalStore((state) => state.setActiveTab);
|
||||
const setConnecting = useTerminalStore((state) => state.setConnecting);
|
||||
const setTabSessionId = useTerminalStore((state) => state.setTabSessionId);
|
||||
const setTabPreviewUrl = useTerminalStore((state) => state.setTabPreviewUrl);
|
||||
const projectActionRuns = useTerminalStore((state) => state.projectActionRuns);
|
||||
const setProjectActionRun = useTerminalStore((state) => state.setProjectActionRun);
|
||||
const updateProjectActionRunStatus = useTerminalStore((state) => state.updateProjectActionRunStatus);
|
||||
const removeProjectActionRun = useTerminalStore((state) => state.removeProjectActionRun);
|
||||
|
||||
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 streamCleanupByRunKeyRef = React.useRef<Record<string, () => void>>({});
|
||||
const previewWaitTimeoutByRunKeyRef = React.useRef<Record<string, number>>({});
|
||||
const loadRequestIdRef = React.useRef(0);
|
||||
|
||||
const projectId = projectRef?.id ?? null;
|
||||
@@ -248,13 +255,13 @@ export const ProjectActionsButton = ({
|
||||
const filtered = state.actions;
|
||||
setActions(filtered);
|
||||
setSelectedActionId((current) => {
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
if (current === AUTO_DISCOVER_ACTION_ID) {
|
||||
return current;
|
||||
}
|
||||
if (current && filtered.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return filtered[0]?.id ?? null;
|
||||
return null;
|
||||
});
|
||||
} catch {
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
@@ -268,6 +275,31 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
}, [stableProjectRef]);
|
||||
|
||||
const normalizedDirectory = React.useMemo(() => {
|
||||
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
|
||||
}, [directory, stableProjectRef?.path]);
|
||||
|
||||
const selectedAction = React.useMemo(() => {
|
||||
if (!selectedActionId) {
|
||||
return null;
|
||||
}
|
||||
return actions.find((entry) => entry.id === selectedActionId) ?? null;
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
const autoDiscoverAction = React.useMemo<OpenChamberProjectAction>(() => ({
|
||||
id: AUTO_DISCOVER_ACTION_ID,
|
||||
name: t('projectActions.actions.autoDiscover'),
|
||||
command: '',
|
||||
icon: 'search',
|
||||
autoOpenUrl: true,
|
||||
}), [t]);
|
||||
|
||||
const canUseAutoDiscover = !isMobile;
|
||||
const displayActions = React.useMemo(
|
||||
() => canUseAutoDiscover ? [autoDiscoverAction, ...actions] : actions,
|
||||
[actions, autoDiscoverAction, canUseAutoDiscover]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadActions();
|
||||
}, [loadActions]);
|
||||
@@ -298,35 +330,29 @@ export const ProjectActionsButton = ({
|
||||
if (!selectedActionId) {
|
||||
return;
|
||||
}
|
||||
if (!actions.some((entry) => entry.id === selectedActionId)) {
|
||||
setSelectedActionId(actions[0]?.id ?? null);
|
||||
if (selectedActionId === AUTO_DISCOVER_ACTION_ID && canUseAutoDiscover) {
|
||||
return;
|
||||
}
|
||||
}, [actions, selectedActionId]);
|
||||
if (!actions.some((entry) => entry.id === selectedActionId)) {
|
||||
setSelectedActionId(null);
|
||||
}
|
||||
}, [actions, canUseAutoDiscover, 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;
|
||||
for (const [key, entry] of Object.entries(projectActionRuns)) {
|
||||
const directoryState = terminalSessions.get(entry.directory);
|
||||
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
|
||||
if (!tab || tab.terminalSessionId !== entry.sessionId) {
|
||||
removeProjectActionRun(key);
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [terminalSessions]);
|
||||
}
|
||||
}, [projectActionRuns, removeProjectActionRun, terminalSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const [runKey, entry] of Object.entries(runningByKey)) {
|
||||
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '' };
|
||||
for (const [runKey, entry] of Object.entries(projectActionRuns)) {
|
||||
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
|
||||
urlWatchByRunKeyRef.current[runKey] = watch;
|
||||
const action = actions.find((item) => item.id === entry.actionId);
|
||||
const action = displayActions.find((item) => item.id === entry.actionId);
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
@@ -358,32 +384,36 @@ export const ProjectActionsButton = ({
|
||||
|
||||
if (maybeUrl) {
|
||||
watch.openedUrl = true;
|
||||
void openExternal(maybeUrl);
|
||||
toast.success(t('projectActions.toast.openedUrlFromOutput'));
|
||||
if (watch.openInPreview) {
|
||||
const run = projectActionRuns[runKey];
|
||||
if (run) {
|
||||
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false });
|
||||
if (run.status === 'waiting-for-preview') {
|
||||
updateProjectActionRunStatus(runKey, 'running');
|
||||
}
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
openContextPreview(run.directory, maybeUrl);
|
||||
}
|
||||
} else {
|
||||
void openExternal(maybeUrl);
|
||||
toast.success(t('projectActions.toast.openedUrlFromOutput'));
|
||||
}
|
||||
}
|
||||
urlWatchByRunKeyRef.current[runKey] = watch;
|
||||
}
|
||||
|
||||
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
|
||||
if (!runningByKey[runKey]) {
|
||||
if (!projectActionRuns[runKey]) {
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
}
|
||||
}
|
||||
|
||||
}, [actions, openExternal, runningByKey, t, terminalSessions]);
|
||||
}, [displayActions, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t, terminalSessions, updateProjectActionRunStatus]);
|
||||
|
||||
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) => {
|
||||
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => {
|
||||
if (!normalizedDirectory) {
|
||||
throw new Error(t('projectActions.error.noActiveDirectory'));
|
||||
}
|
||||
@@ -405,10 +435,12 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
|
||||
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
|
||||
setActiveTab(normalizedDirectory, tabId);
|
||||
|
||||
setBottomTerminalOpen(true);
|
||||
setActiveMainTab('terminal');
|
||||
setTabIconKey(normalizedDirectory, tabId, action.icon || 'play');
|
||||
if (options.revealTerminal !== false) {
|
||||
setActiveTab(normalizedDirectory, tabId);
|
||||
setBottomTerminalOpen(true);
|
||||
setActiveMainTab('terminal');
|
||||
}
|
||||
|
||||
const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory);
|
||||
const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId);
|
||||
@@ -423,6 +455,7 @@ export const ProjectActionsButton = ({
|
||||
setActiveMainTab,
|
||||
setActiveTab,
|
||||
setBottomTerminalOpen,
|
||||
setTabIconKey,
|
||||
setTabLabel,
|
||||
t,
|
||||
]);
|
||||
@@ -438,13 +471,35 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const existingRun = runningByKey[runKey];
|
||||
const existingRun = projectActionRuns[runKey];
|
||||
if (existingRun && existingRun.status === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, tabId, sessionId } = await getOrCreateActionTab(action);
|
||||
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
|
||||
? await (async (): Promise<OpenChamberProjectAction> => {
|
||||
const [actionsState, scripts] = await Promise.all([
|
||||
getProjectActionsState({ id: stableProjectRef?.id ?? '', path: normalizedDirectory }),
|
||||
readPackageJsonScripts(normalizedDirectory),
|
||||
]);
|
||||
const devServer = await detectDevServerCommand(normalizedDirectory, actionsState.actions, scripts);
|
||||
if (!devServer) {
|
||||
throw new Error(t('contextPanel.preview.noDevServer'));
|
||||
}
|
||||
return {
|
||||
id: AUTO_DISCOVER_ACTION_ID,
|
||||
name: t('projectActions.actions.autoDiscover'),
|
||||
command: devServer.command,
|
||||
icon: 'search',
|
||||
autoOpenUrl: true,
|
||||
openUrl: devServer.previewUrlHint || '',
|
||||
};
|
||||
})()
|
||||
: action;
|
||||
|
||||
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
|
||||
const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal: !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID });
|
||||
let activeSessionId = sessionId;
|
||||
let createdSession = false;
|
||||
|
||||
@@ -468,54 +523,92 @@ export const ProjectActionsButton = ({
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
key,
|
||||
directory: normalizedDirectory,
|
||||
actionId: action.id,
|
||||
tabId,
|
||||
sessionId: activeSessionId,
|
||||
status: 'running',
|
||||
},
|
||||
}));
|
||||
if (discovered.id === AUTO_DISCOVER_ACTION_ID) {
|
||||
streamCleanupByRunKeyRef.current[key]?.();
|
||||
setConnecting(normalizedDirectory, tabId, true);
|
||||
streamCleanupByRunKeyRef.current[key] = connectTerminalStream(
|
||||
activeSessionId,
|
||||
(event) => {
|
||||
if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) {
|
||||
useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data);
|
||||
}
|
||||
if (event.type === 'exit') {
|
||||
useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited');
|
||||
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
|
||||
useTerminalStore.getState().removeProjectActionRun(key);
|
||||
delete urlWatchByRunKeyRef.current[key];
|
||||
streamCleanupByRunKeyRef.current[key]?.();
|
||||
delete streamCleanupByRunKeyRef.current[key];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[key];
|
||||
}
|
||||
},
|
||||
() => {
|
||||
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
|
||||
},
|
||||
{ maxRetries: 60, initialRetryDelay: 250, maxRetryDelay: 2000, connectionTimeout: 5000 },
|
||||
);
|
||||
}
|
||||
|
||||
const hasCustomOpenUrl = action.autoOpenUrl === true && (action.openUrl || '').trim().length > 0;
|
||||
const hasDesktopForwardSelection = action.autoOpenUrl === true
|
||||
const hasDesktopForwardSelection = discovered.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)
|
||||
&& (discovered.desktopOpenSshForward || '').trim().length > 0;
|
||||
const manualOpenUrl = discovered.autoOpenUrl ? normalizeManualOpenUrl(discovered.openUrl) : null;
|
||||
const desktopForwardUrl = discovered.autoOpenUrl && isDesktopShellApp
|
||||
? resolveProjectActionDesktopForwardUrl(discovered.desktopOpenSshForward, desktopSshInstances)
|
||||
: null;
|
||||
|
||||
setProjectActionRun({
|
||||
key,
|
||||
directory: normalizedDirectory,
|
||||
actionId: discovered.id,
|
||||
tabId,
|
||||
sessionId: activeSessionId,
|
||||
status: discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl ? 'waiting-for-preview' : 'running',
|
||||
});
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[key];
|
||||
if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) {
|
||||
previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => {
|
||||
useTerminalStore.getState().updateProjectActionRunStatus(key, 'running');
|
||||
delete previewWaitTimeoutByRunKeyRef.current[key];
|
||||
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (desktopForwardUrl) {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
|
||||
void openExternal(desktopForwardUrl);
|
||||
toast.success(t('projectActions.toast.openedForwardedUrl'));
|
||||
} else if (manualOpenUrl) {
|
||||
void openExternal(manualOpenUrl);
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true });
|
||||
openContextPreview(normalizedDirectory, manualOpenUrl);
|
||||
toast.success(t('projectActions.toast.openedActionUrl'));
|
||||
} else if (hasCustomOpenUrl) {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
|
||||
toast.error(t('projectActions.error.invalidCustomUrlFormat'));
|
||||
} else if (hasDesktopForwardSelection) {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
|
||||
toast.error(t('projectActions.error.selectedDesktopSshForwardUnavailable'));
|
||||
} else {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false });
|
||||
}
|
||||
|
||||
urlWatchByRunKeyRef.current[key] = {
|
||||
lastSeenChunkId: null,
|
||||
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
|
||||
tail: '',
|
||||
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
|
||||
};
|
||||
|
||||
const normalizedCommand = stripControlChars(action.command.trim().replace(/\r\n|\r/g, '\n'));
|
||||
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
|
||||
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
|
||||
} catch (error) {
|
||||
setRunningByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runKey];
|
||||
return next;
|
||||
});
|
||||
removeProjectActionRun(runKey);
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
streamCleanupByRunKeyRef.current[runKey]?.();
|
||||
delete streamCleanupByRunKeyRef.current[runKey];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
|
||||
}
|
||||
}, [
|
||||
@@ -526,28 +619,27 @@ export const ProjectActionsButton = ({
|
||||
isDesktopShellApp,
|
||||
normalizedDirectory,
|
||||
openExternal,
|
||||
runningByKey,
|
||||
openContextPreview,
|
||||
projectActionRuns,
|
||||
runtime.isVSCode,
|
||||
removeProjectActionRun,
|
||||
setConnecting,
|
||||
setProjectActionRun,
|
||||
setTabPreviewUrl,
|
||||
setTabSessionId,
|
||||
stableProjectRef?.id,
|
||||
t,
|
||||
terminal,
|
||||
]);
|
||||
|
||||
const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => {
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const activeRun = runningByKey[runKey];
|
||||
const activeRun = projectActionRuns[runKey];
|
||||
if (!activeRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => ({
|
||||
...prev,
|
||||
[runKey]: {
|
||||
...activeRun,
|
||||
status: 'stopping',
|
||||
},
|
||||
}));
|
||||
updateProjectActionRunStatus(runKey, 'stopping');
|
||||
|
||||
try {
|
||||
await terminal.sendInput(activeRun.sessionId, '\x03');
|
||||
@@ -581,29 +673,30 @@ export const ProjectActionsButton = ({
|
||||
setTabSessionId(activeRun.directory, activeRun.tabId, null);
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runKey];
|
||||
return next;
|
||||
});
|
||||
removeProjectActionRun(runKey);
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
}, [normalizedDirectory, runningByKey, setTabSessionId, terminal]);
|
||||
streamCleanupByRunKeyRef.current[runKey]?.();
|
||||
delete streamCleanupByRunKeyRef.current[runKey];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
}, [normalizedDirectory, projectActionRuns, removeProjectActionRun, setTabSessionId, terminal, updateProjectActionRunStatus]);
|
||||
|
||||
const handlePrimaryClick = React.useCallback(() => {
|
||||
if (!selectedAction) {
|
||||
const action = selectedAction ?? displayActions[0];
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, selectedAction.id);
|
||||
const runningEntry = runningByKey[runKey];
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const runningEntry = projectActionRuns[runKey];
|
||||
if (runningEntry?.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
if (runningEntry) {
|
||||
void stopAction(selectedAction);
|
||||
void stopAction(action);
|
||||
return;
|
||||
}
|
||||
void runAction(selectedAction);
|
||||
}, [normalizedDirectory, runAction, runningByKey, selectedAction, stopAction]);
|
||||
void runAction(action);
|
||||
}, [displayActions, normalizedDirectory, runAction, projectActionRuns, selectedAction, stopAction]);
|
||||
|
||||
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
|
||||
setSelectedActionId(action.id);
|
||||
@@ -614,7 +707,7 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const runningEntry = runningByKey[runKey];
|
||||
const runningEntry = projectActionRuns[runKey];
|
||||
if (runningEntry?.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
@@ -623,7 +716,7 @@ export const ProjectActionsButton = ({
|
||||
return;
|
||||
}
|
||||
void runAction(action);
|
||||
}, [normalizedDirectory, runAction, runningByKey, stopAction]);
|
||||
}, [normalizedDirectory, runAction, projectActionRuns, stopAction]);
|
||||
|
||||
const openProjectActionsSettings = React.useCallback(() => {
|
||||
if (!stableProjectRef?.id) {
|
||||
@@ -638,117 +731,120 @@ export const ProjectActionsButton = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] 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',
|
||||
className
|
||||
)}
|
||||
aria-label={t('projectActions.actions.addActionAria')}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-7 shrink-0 items-center gap-2 self-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px]',
|
||||
'bg-[var(--surface-elevated)] px-3 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',
|
||||
'border border-border/60',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="header-open-label whitespace-nowrap">{t('projectActions.actions.addAction')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSelected = selectedAction ?? actions[0] ?? null;
|
||||
const resolvedSelected = selectedAction ?? displayActions[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 SelectedIcon = resolvedSelected.id === AUTO_DISCOVER_ACTION_ID
|
||||
? RiSearchLine
|
||||
: PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const selectedButtonLabel = formatActionButtonLabel(
|
||||
resolvedSelected.name,
|
||||
t('projectActions.label.fallbackAction'),
|
||||
);
|
||||
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
|
||||
const selectedRunning = runningByKey[selectedRunKey];
|
||||
const selectedRunning = projectActionRuns[selectedRunKey];
|
||||
const isStoppingSelected = selectedRunning?.status === 'stopping';
|
||||
const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview';
|
||||
const selectedRunPreviewUrl = selectedRunning
|
||||
? terminalSessions.get(selectedRunning.directory)?.tabs.find((tab) => tab.id === selectedRunning.tabId)?.previewUrl ?? null
|
||||
: null;
|
||||
const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl);
|
||||
const handleOpenSelectedPreview = () => {
|
||||
if (!selectedRunning || !selectedRunPreviewUrl) {
|
||||
return;
|
||||
}
|
||||
openContextPreview(selectedRunning.directory, selectedRunPreviewUrl);
|
||||
};
|
||||
|
||||
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-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] 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:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
aria-label={selectedRunning
|
||||
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
|
||||
: t('projectActions.actions.runNamedAria', { name: 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">{t('projectActions.actions.addNewAction')}</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);
|
||||
}}
|
||||
<div className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] 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:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
onClick={handlePrimaryClick}
|
||||
aria-label={selectedRunning
|
||||
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
|
||||
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
||||
>
|
||||
{isStoppingSelected || isWaitingForSelectedPreview
|
||||
? <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>
|
||||
{showSelectedPreviewButton ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="app-region-no-drag -ml-1 inline-flex h-9 w-7 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('projectActions.actions.openPreview')}
|
||||
onClick={handleOpenSelectedPreview}
|
||||
>
|
||||
<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>
|
||||
<RiGlobalLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('projectActions.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="app-region-no-drag -ml-1 inline-flex h-9 w-5 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('projectActions.actions.chooseActionAria')}
|
||||
>
|
||||
<RiArrowDownSLine className="h-3.5 w-3.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">{t('projectActions.actions.addNewAction')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{displayActions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = entry.id === AUTO_DISCOVER_ACTION_ID
|
||||
? RiSearchLine
|
||||
: PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = projectActionRuns[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 || runState?.status === 'waiting-for-preview'
|
||||
? <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -776,7 +872,7 @@ export const ProjectActionsButton = ({
|
||||
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
||||
>
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{isStoppingSelected
|
||||
{isStoppingSelected || isWaitingForSelectedPreview
|
||||
? <RiLoader4Line className="h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: selectedRunning
|
||||
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
|
||||
@@ -785,6 +881,26 @@ export const ProjectActionsButton = ({
|
||||
{!compact ? <span className="header-open-label whitespace-nowrap">{selectedButtonLabel}</span> : null}
|
||||
</button>
|
||||
|
||||
{showSelectedPreviewButton ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSelectedPreview}
|
||||
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-foreground',
|
||||
'hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
aria-label={t('projectActions.actions.openPreview')}
|
||||
>
|
||||
<RiGlobalLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('projectActions.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -805,11 +921,13 @@ export const ProjectActionsButton = ({
|
||||
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((entry) => {
|
||||
{displayActions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const Icon = entry.id === AUTO_DISCOVER_ACTION_ID
|
||||
? RiSearchLine
|
||||
: PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = runningByKey[runKey];
|
||||
const runState = projectActionRuns[runKey];
|
||||
const isRunning = Boolean(runState);
|
||||
const isStopping = runState?.status === 'stopping';
|
||||
|
||||
@@ -823,7 +941,7 @@ export const ProjectActionsButton = ({
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
||||
{isStopping
|
||||
{isStopping || runState?.status === 'waiting-for-preview'
|
||||
? <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)]" />
|
||||
|
||||
@@ -435,12 +435,12 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
<>
|
||||
{item.icon ? (
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && 'group-hover:opacity-0')}>{item.icon}</span>
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && (isMobile ? 'opacity-0' : 'group-hover:opacity-0'))}>{item.icon}</span>
|
||||
{closeReplacesIcon ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
@@ -467,12 +467,12 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
isActive ? 'text-[var(--primary-base)]' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && 'group-hover:opacity-0')}>{item.icon}</span>
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && (isMobile ? 'opacity-0' : 'group-hover:opacity-0'))}>{item.icon}</span>
|
||||
{closeReplacesIcon ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
|
||||
@@ -1223,7 +1223,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<string> => {
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, options);
|
||||
return result.content ?? '';
|
||||
@@ -1233,7 +1233,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
if (options?.optional) {
|
||||
params.set('optional', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: options?.optional ? 'no-store' : 'default',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
@@ -365,7 +366,16 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
|
||||
return result?.content ?? '';
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (runtimeFiles?.readFile) {
|
||||
const result = await runtimeFiles.readFile(path, { optional: true });
|
||||
return result?.content ?? '';
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read plan file (${response.status})`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine, RiFullscreenExitLine, RiFullscreenLine, RiGlobalLine, RiTerminalLine } from '@remixicon/react';
|
||||
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
@@ -13,10 +13,12 @@ import { TerminalViewport, type TerminalController } from '@/components/terminal
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { primeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
||||
|
||||
type Modifier = 'ctrl' | 'cmd';
|
||||
type MobileKey =
|
||||
@@ -111,6 +113,8 @@ export const TerminalView: React.FC = () => {
|
||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
const directoryTerminalState = React.useMemo(() => {
|
||||
if (!effectiveDirectory) return undefined;
|
||||
return terminalSessions.get(effectiveDirectory);
|
||||
@@ -133,10 +137,24 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
}, [directoryTerminalState, activeTabId]);
|
||||
|
||||
const terminalTabItems = React.useMemo(() => {
|
||||
return (directoryTerminalState?.tabs ?? []).map((tab) => ({
|
||||
icon: (() => {
|
||||
const Icon = tab.iconKey ? PROJECT_ACTION_ICON_MAP[tab.iconKey as ProjectActionIconKey] ?? RiTerminalLine : RiTerminalLine;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
})(),
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
title: tab.label,
|
||||
closeLabel: t('terminalView.tabs.closeTabTitle'),
|
||||
}));
|
||||
}, [directoryTerminalState?.tabs, t]);
|
||||
|
||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||||
const isConnecting = activeTab?.isConnecting ?? false;
|
||||
const previewUrl = activeTab?.previewUrl ?? null;
|
||||
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||
@@ -187,6 +205,8 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
|
||||
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
|
||||
const isTerminalActive = activeMainTab === 'terminal';
|
||||
const isTerminalVisible = isTerminalActive || isBottomTerminalOpen;
|
||||
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
|
||||
@@ -905,6 +925,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
|
||||
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||||
const showBottomDockControls = !isMobile && isBottomTerminalOpen && !isTerminalActive;
|
||||
const quickKeysControls = (
|
||||
<>
|
||||
<Button
|
||||
@@ -1012,73 +1033,82 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
||||
<div className={cn('sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'px-4 py-1.5' : runtime.platform === 'desktop' ? 'pl-5 pr-20 py-2' : 'px-5 py-2')}>
|
||||
<div className={cn('app-region-no-drag sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'pl-3 pr-1.5 py-1' : 'pl-3 pr-1.5 py-1')}>
|
||||
{enableTabs && directoryTerminalState ? (
|
||||
<div className={cn('pl-1 pr-1 flex items-center gap-2', isMobile ? 'mt-1' : 'mt-2')}>
|
||||
<div className={cn('min-w-0 flex-1 overflow-x-auto', isMobile ? 'pb-0.5' : 'pb-1')}>
|
||||
<div className={cn('flex w-max items-center pr-1', isMobile ? 'gap-1' : 'gap-1')}>
|
||||
{directoryTerminalState.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'group flex items-center rounded-md border whitespace-nowrap',
|
||||
isMobile ? 'h-8 gap-0.5 pl-2 pr-1.5 text-sm leading-none' : 'gap-1 pl-2 pr-1 py-1 text-xs',
|
||||
isActive
|
||||
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
||||
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectTab(tab.id)}
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
isMobile ? '!min-h-0 !min-w-0 max-w-[9.5rem]' : 'max-w-[10rem]'
|
||||
)}
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-sm text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||
isMobile ? '!min-h-0 !min-w-0 h-3.5 w-3.5 p-0 leading-none' : 'h-4 w-4 p-0 leading-none',
|
||||
!isMobile && !isActive && 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseTab(tab.id);
|
||||
}}
|
||||
title={t('terminalView.tabs.closeTabTitle')}
|
||||
>
|
||||
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTab}
|
||||
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={t('terminalView.tabs.newTabTitle')}
|
||||
>
|
||||
<RiAddLine size={isMobile ? 18 : 16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-1 pr-1">
|
||||
<div className={cn('min-w-0 flex-1', isMobile ? 'h-8' : 'h-7')}>
|
||||
<SortableTabsStrip
|
||||
items={terminalTabItems}
|
||||
activeId={activeTabId}
|
||||
onSelect={handleSelectTab}
|
||||
onClose={handleCloseTab}
|
||||
layoutMode="scrollable"
|
||||
variant="default"
|
||||
className="h-full bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isMobile && showQuickKeys ? (
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
{quickKeysControls}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className={cn('shrink-0', isMobile ? 'h-8 w-8 p-0' : 'h-7 w-7 p-0')}
|
||||
onClick={handleCreateTab}
|
||||
title={t('terminalView.tabs.newTabTitle')}
|
||||
>
|
||||
<RiAddLine size={isMobile ? 18 : 16} />
|
||||
</Button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-visible">
|
||||
{previewUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="h-6 shrink-0 gap-1 px-2"
|
||||
onClick={() => {
|
||||
if (!effectiveDirectory) return;
|
||||
openContextPreview(effectiveDirectory, previewUrl);
|
||||
}}
|
||||
title={t('terminalView.preview.openTitle')}
|
||||
>
|
||||
<RiGlobalLine className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="whitespace-nowrap">{t('terminalView.preview.open')}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{showBottomDockControls ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setBottomTerminalExpanded(!isBottomTerminalExpanded)}
|
||||
className={cn('shrink-0 p-0', isMobile ? 'h-8 w-8' : 'h-7 w-7')}
|
||||
title={isBottomTerminalExpanded ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
|
||||
aria-label={isBottomTerminalExpanded ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
|
||||
>
|
||||
{isBottomTerminalExpanded ? <RiFullscreenExitLine className="h-4 w-4" /> : <RiFullscreenLine className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setBottomTerminalOpen(false)}
|
||||
className={cn('shrink-0 p-0', isMobile ? 'h-8 w-8' : 'h-7 w-7')}
|
||||
title={t('terminalView.bottomDock.closeTitle')}
|
||||
aria-label={t('terminalView.bottomDock.closeAria')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isMobile && showQuickKeys && enableTabs && directoryTerminalState ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 pl-1 pr-1">
|
||||
{quickKeysControls}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1093,7 +1123,7 @@ export const TerminalView: React.FC = () => {
|
||||
className="relative flex-1 overflow-hidden"
|
||||
style={{ backgroundColor: xtermTheme.background }}
|
||||
>
|
||||
<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-4 pr-1.5 pt-3 pb-4">
|
||||
{shouldRenderViewport ? (
|
||||
<TerminalViewport
|
||||
key={viewportSessionKey}
|
||||
|
||||
@@ -511,6 +511,7 @@ export interface ListDirectoryOptions {
|
||||
|
||||
export interface FileReadOptions {
|
||||
allowOutsideWorkspace?: boolean;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
|
||||
@@ -26,12 +26,15 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||
|
||||
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, { allowOutsideWorkspace: true });
|
||||
const result = await files.readFile(path, { allowOutsideWorkspace: true, optional: true });
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true' });
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true', optional: 'true' });
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((errorPayload as { error?: string }).error || 'Failed to read file');
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { OpenChamberProjectAction } from './openchamberConfig';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
type DevServerInfo = {
|
||||
command: string;
|
||||
label: string;
|
||||
actionId?: string;
|
||||
previewUrlHint?: string;
|
||||
};
|
||||
|
||||
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
|
||||
|
||||
const DEV_COMMAND_PATTERNS = [
|
||||
{ pattern: /^dev(:.*)?$/i },
|
||||
{ pattern: /^start(:.*)?$/i },
|
||||
{ pattern: /^preview(:.*)?$/i },
|
||||
{ pattern: /^serve(:.*)?$/i },
|
||||
{ pattern: /^develop(:.*)?$/i },
|
||||
];
|
||||
|
||||
const COMMON_DEV_COMMANDS = [
|
||||
'dev',
|
||||
'start',
|
||||
'preview',
|
||||
'serve',
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect the dev server command from project actions or package.json scripts
|
||||
*/
|
||||
export async function detectDevServerCommand(
|
||||
directory: string,
|
||||
projectActions: OpenChamberProjectAction[],
|
||||
packageJsonScripts: Record<string, string> | null,
|
||||
): Promise<DevServerInfo | null> {
|
||||
if (!directory) return null;
|
||||
|
||||
// First, check if there's a project action that looks like a dev server
|
||||
const devAction = findDevServerAction(projectActions);
|
||||
if (devAction) {
|
||||
return {
|
||||
command: devAction.command,
|
||||
label: devAction.name || 'Start Preview',
|
||||
actionId: devAction.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Then, check package.json scripts
|
||||
if (packageJsonScripts) {
|
||||
const devScript = findDevScript(packageJsonScripts);
|
||||
if (devScript) {
|
||||
// Determine the package manager command
|
||||
const pm = await detectPackageManager(directory);
|
||||
const pmCommand = pm === 'npm' ? 'npm run' : pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : pm === 'bun' ? 'bun' : 'npm run';
|
||||
return {
|
||||
command: `${pmCommand} ${devScript}`,
|
||||
label: `Start (${devScript})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: static sites (no package.json) can be previewed via a simple file server.
|
||||
// This keeps Start Preview usable for non-Node projects.
|
||||
if (await hasStaticIndexHtml(directory)) {
|
||||
const port = await allocatePreviewPort();
|
||||
const resolvedPort = typeof port === 'number' && Number.isFinite(port) && port > 0 ? port : 8000;
|
||||
return {
|
||||
command: `python3 -m http.server ${resolvedPort}`,
|
||||
label: 'Static preview',
|
||||
previewUrlHint: `http://127.0.0.1:${resolvedPort}/`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hasStaticIndexHtml(directory: string): Promise<boolean> {
|
||||
const target = `${directory}/index.html`;
|
||||
const content = await readOptionalTextFile(target);
|
||||
return typeof content === 'string' && content.trim().length > 0;
|
||||
}
|
||||
|
||||
async function allocatePreviewPort(): Promise<number | null> {
|
||||
try {
|
||||
const response = await fetch('/api/system/free-port', { cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const body = await response.json().catch(() => null) as { port?: unknown } | null;
|
||||
const port = typeof body?.port === 'number' ? body.port : null;
|
||||
return port && Number.isFinite(port) ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project action that looks like a dev server
|
||||
*/
|
||||
function findDevServerAction(actions: OpenChamberProjectAction[]): OpenChamberProjectAction | null {
|
||||
// Look for actions with "dev", "preview", "start" in the name or command
|
||||
for (const action of actions) {
|
||||
const nameAndCommand = `${action.name} ${action.command}`.toLowerCase();
|
||||
|
||||
// Check if it's likely a dev server action
|
||||
const isDevAction = COMMON_DEV_COMMANDS.some(cmd =>
|
||||
nameAndCommand.includes(cmd)
|
||||
);
|
||||
|
||||
if (isDevAction) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the first action if there's only one
|
||||
if (actions.length === 1) {
|
||||
return actions[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a dev script in package.json scripts
|
||||
*/
|
||||
function findDevScript(scripts: Record<string, string>): string | null {
|
||||
for (const { pattern } of DEV_COMMAND_PATTERNS) {
|
||||
for (const scriptName of Object.keys(scripts)) {
|
||||
if (pattern.test(scriptName)) {
|
||||
return scriptName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple package manager detection based on lock files
|
||||
* Note: This is intentionally a simple client-side check.
|
||||
* For server-side operations, the server's package-manager.js is used.
|
||||
*/
|
||||
async function detectPackageManager(directory: string): Promise<PackageManager> {
|
||||
const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`);
|
||||
if (packageJsonContent) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJsonContent) as { packageManager?: unknown };
|
||||
const packageManager = typeof pkg.packageManager === 'string' ? pkg.packageManager.toLowerCase() : '';
|
||||
if (packageManager.startsWith('bun@')) return 'bun';
|
||||
if (packageManager.startsWith('pnpm@')) return 'pnpm';
|
||||
if (packageManager.startsWith('yarn@')) return 'yarn';
|
||||
if (packageManager.startsWith('npm@')) return 'npm';
|
||||
} catch {
|
||||
// Ignore malformed package.json here; readPackageJsonScripts handles it separately.
|
||||
}
|
||||
}
|
||||
|
||||
const lockfiles: Array<[string, PackageManager]> = [
|
||||
['bun.lock', 'bun'],
|
||||
['bun.lockb', 'bun'],
|
||||
['pnpm-lock.yaml', 'pnpm'],
|
||||
['yarn.lock', 'yarn'],
|
||||
['package-lock.json', 'npm'],
|
||||
];
|
||||
|
||||
for (const [fileName, packageManager] of lockfiles) {
|
||||
const content = await readOptionalTextFile(`${directory}/${fileName}`);
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return packageManager;
|
||||
}
|
||||
}
|
||||
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string | null> {
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.readFile(path, { optional: true });
|
||||
return typeof result?.content === 'string' ? result.content : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package.json scripts from a directory
|
||||
*/
|
||||
export async function readPackageJsonScripts(directory: string): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const content = await readOptionalTextFile(`${directory}/package.json`);
|
||||
|
||||
if (content == null) return null;
|
||||
const pkg = JSON.parse(content);
|
||||
|
||||
return pkg.scripts || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -695,12 +695,61 @@ export const dict = {
|
||||
'contextPanel.mode.diff': 'Diff',
|
||||
'contextPanel.mode.plan': 'Plan',
|
||||
'contextPanel.mode.context': 'Context',
|
||||
'contextPanel.mode.preview': 'Preview',
|
||||
'contextPanel.tab.closeTabAria': 'Close {label} tab',
|
||||
'contextPanel.actions.collapsePanel': 'Collapse panel',
|
||||
'contextPanel.actions.expandPanel': 'Expand panel',
|
||||
'contextPanel.actions.closePanel': 'Close panel',
|
||||
'contextPanel.actions.resizePanelAria': 'Resize context panel',
|
||||
'contextPanel.iframe.sessionChatTitle': 'Session chat {sessionID}',
|
||||
'contextPanel.preview.actions.reload': 'Reload preview',
|
||||
'contextPanel.preview.actions.openExternal': 'Open in browser',
|
||||
'contextPanel.preview.actions.retry': 'Retry',
|
||||
'contextPanel.preview.iframeTitle': 'Preview',
|
||||
'contextPanel.preview.invalidUrl': 'Preview needs a valid http(s) URL.',
|
||||
'contextPanel.preview.empty': 'No preview URL',
|
||||
'contextPanel.preview.loading': 'Connecting preview proxy...',
|
||||
'contextPanel.preview.proxyError': 'Could not start preview proxy.',
|
||||
'contextPanel.preview.upstreamUnreachable': 'Dev server is not responding.',
|
||||
'contextPanel.preview.upstreamUnreachableHint': 'Make sure your dev server is still running, then retry.',
|
||||
'contextPanel.preview.startingServer': 'Starting dev server...',
|
||||
'contextPanel.preview.startingServerHint': 'Waiting for the server to accept connections.',
|
||||
'contextPanel.preview.title': 'Preview',
|
||||
'contextPanel.preview.description': 'Use Project Actions or a terminal Preview button to open a preview.',
|
||||
'contextPanel.preview.startPreview': 'Start Preview',
|
||||
'contextPanel.preview.starting': 'Starting...',
|
||||
'contextPanel.preview.noDevServer': 'No dev server command found. Configure a project action or add a "dev" script to package.json.',
|
||||
'contextPanel.preview.startFailed': 'Failed to start preview server.',
|
||||
'contextPanel.preview.serverExited': 'Dev server exited unexpectedly.',
|
||||
'contextPanel.preview.noUrlDetected': 'Dev server started, but no URL was detected in its output. Open the Preview terminal tab to check logs.',
|
||||
'contextPanel.preview.serverExitedWithLog': 'Dev server exited unexpectedly. Last output:\n\n{log}',
|
||||
'contextPanel.preview.noUrlDetectedWithLog': 'Dev server did not become reachable. Last output:\n\n{log}',
|
||||
'contextPanel.preview.console.open': 'Open preview console',
|
||||
'contextPanel.preview.console.waiting': 'Waiting for preview console',
|
||||
'contextPanel.preview.console.title': 'Preview console',
|
||||
'contextPanel.preview.console.attach': 'Attach',
|
||||
'contextPanel.preview.console.copy': 'Copy',
|
||||
'contextPanel.preview.console.clear': 'Clear',
|
||||
'contextPanel.preview.console.empty': 'No preview console events yet.',
|
||||
'contextPanel.preview.console.noFilteredEvents': 'No events match this filter.',
|
||||
'contextPanel.preview.console.runtimeError': 'Runtime error',
|
||||
'contextPanel.preview.console.copied': 'Preview console copied',
|
||||
'contextPanel.preview.console.copyFailed': 'Failed to copy preview console',
|
||||
'contextPanel.preview.console.attached': 'Preview console attached to chat',
|
||||
'contextPanel.preview.console.attachNoSession': 'Open a chat session before attaching preview logs',
|
||||
'contextPanel.preview.console.attachAnnotation': 'These are browser console logs from the dev server running for this project.',
|
||||
'contextPanel.preview.inspect.toggle': 'Inspect preview element',
|
||||
'contextPanel.preview.inspect.attached': 'Preview annotation attached to chat',
|
||||
'contextPanel.preview.inspect.attachNoSession': 'Open a chat session before attaching preview annotations',
|
||||
'contextPanel.preview.inspect.attachAnnotation': 'This is a selected DOM element from the in-app preview.',
|
||||
'contextPanel.preview.inspect.attachAnnotationWithScreenshot': 'This is a selected DOM element from the in-app preview. A screenshot of the visible preview area with the selected element highlighted is attached.',
|
||||
'contextPanel.preview.console.filter.all': 'All',
|
||||
'contextPanel.preview.console.filter.errors': 'Errors',
|
||||
'contextPanel.preview.console.filter.warnings': 'Warnings',
|
||||
'contextPanel.preview.console.filter.logs': 'Logs',
|
||||
|
||||
'terminalView.preview.open': 'Preview',
|
||||
'terminalView.preview.openTitle': 'Open preview pane',
|
||||
'sidebarFilesTree.menu.rename': 'Rename',
|
||||
'sidebarFilesTree.menu.copyPath': 'Copy Path',
|
||||
'sidebarFilesTree.menu.save': 'Save',
|
||||
@@ -958,6 +1007,7 @@ export const dict = {
|
||||
'header.services.used': 'Used',
|
||||
'header.services.remaining': 'Remaining',
|
||||
'header.services.modelFamily.other': 'Other',
|
||||
'header.services.shutdownDev': 'Stop OpenChamber',
|
||||
'header.actions.openPlanAria': 'Open plan',
|
||||
'header.actions.planWithShortcut': 'Plan ({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
|
||||
@@ -1329,6 +1379,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.fork': 'Fork from here',
|
||||
'chat.messageBody.actions.copyMessageAria': 'Copy message text',
|
||||
'chat.messageBody.actions.copyMessage': 'Copy message',
|
||||
'chat.messageBody.actions.openPreviewAria': 'Open preview',
|
||||
'chat.messageBody.actions.openPreview': 'Open preview',
|
||||
'chat.messageBody.actions.copyAnswer': 'Copy answer',
|
||||
'chat.messageBody.actions.savingImage': 'Saving image...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Save as image',
|
||||
@@ -1382,6 +1434,11 @@ export const dict = {
|
||||
'chat.chatInput.toast.openSessionFirst': 'Open a session first',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Failed to toggle permission auto-accept',
|
||||
'chat.chatInput.reviewComments': 'Review comments:',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server logs:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Remove Dev Server logs',
|
||||
'chat.chatInput.previewAnnotations': 'Preview annotations:',
|
||||
'chat.chatInput.previewContext': 'Preview context:',
|
||||
'chat.chatInput.previewContextRemove': 'Remove preview context',
|
||||
'chat.chatInput.projectRoot': 'Project root',
|
||||
'chat.chatInput.branch': 'Branch',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
@@ -1762,7 +1819,9 @@ export const dict = {
|
||||
'projectActions.actions.addActionAria': 'Add action',
|
||||
'projectActions.actions.addAction': 'Add action',
|
||||
'projectActions.actions.addNewAction': 'Add new action',
|
||||
'projectActions.actions.autoDiscover': 'Auto-discover',
|
||||
'projectActions.actions.chooseActionAria': 'Choose project action',
|
||||
'projectActions.actions.openPreview': 'Open Preview',
|
||||
'projectActions.actions.runNamedAria': 'Run {name}',
|
||||
'projectActions.actions.stopNamedAria': 'Stop {name}',
|
||||
'projectActions.label.fallbackAction': 'Action',
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.diff": "Diff",
|
||||
"contextPanel.mode.plan": "Plan",
|
||||
"contextPanel.mode.context": "Contexto",
|
||||
"contextPanel.mode.preview": "Vista previa",
|
||||
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
|
||||
"contextPanel.actions.collapsePanel": "Colapsar panel",
|
||||
"contextPanel.actions.expandPanel": "Expandir panel",
|
||||
"contextPanel.actions.closePanel": "Cerrar panel",
|
||||
"contextPanel.actions.resizePanelAria": "Ajustar tamaño del panel de contexto",
|
||||
"contextPanel.iframe.sessionChatTitle": "Chat de sesión {sessionID}",
|
||||
"contextPanel.preview.actions.reload": "Recargar vista previa",
|
||||
"contextPanel.preview.actions.openExternal": "Abrir en el navegador",
|
||||
"contextPanel.preview.actions.retry": "Reintentar",
|
||||
"contextPanel.preview.iframeTitle": "Vista previa",
|
||||
"contextPanel.preview.invalidUrl": "La vista previa necesita una URL http(s) válida.",
|
||||
"contextPanel.preview.empty": "Sin URL de vista previa",
|
||||
"contextPanel.preview.loading": "Conectando proxy de vista previa...",
|
||||
"contextPanel.preview.proxyError": "No se pudo iniciar el proxy de vista previa.",
|
||||
"contextPanel.preview.upstreamUnreachable": "El servidor de desarrollo no responde.",
|
||||
"contextPanel.preview.upstreamUnreachableHint": "Verifica que tu servidor de desarrollo siga en ejecución y vuelve a intentarlo.",
|
||||
|
||||
"terminalView.preview.open": "Vista previa",
|
||||
"terminalView.preview.openTitle": "Abrir panel de vista previa",
|
||||
"sidebarFilesTree.menu.rename": "Cambiar nombre",
|
||||
"sidebarFilesTree.menu.copyPath": "Copiar ruta",
|
||||
"sidebarFilesTree.menu.save": "Guardar",
|
||||
@@ -959,6 +973,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.services.used": "Usado",
|
||||
"header.services.remaining": "Restante",
|
||||
"header.services.modelFamily.other": "Otro",
|
||||
"header.services.shutdownDev": "Detener OpenChamber",
|
||||
"header.actions.openPlanAria": "Abrir plan",
|
||||
"header.actions.planWithShortcut": "Plan ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Bifurcar desde aquí",
|
||||
"chat.messageBody.actions.copyMessageAria": "Copiar texto del mensaje",
|
||||
"chat.messageBody.actions.copyMessage": "Copiar mensaje",
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir vista previa",
|
||||
"chat.messageBody.actions.openPreview": "Abrir vista previa",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar respuesta",
|
||||
"chat.messageBody.actions.savingImage": "Guardando imagen...",
|
||||
"chat.messageBody.actions.saveAsImage": "Guardar como imagen",
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.openSessionFirst": "Abre una sesión primero",
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "No se pudo cambiar la aceptación automática de permisos",
|
||||
"chat.chatInput.reviewComments": "Comentarios de revisión:",
|
||||
"chat.chatInput.devServerLogs": "Logs del Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Quitar logs del Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Anotaciones de vista previa:",
|
||||
"chat.chatInput.previewContext": "Contexto de vista previa:",
|
||||
"chat.chatInput.previewContextRemove": "Quitar contexto de vista previa",
|
||||
"chat.chatInput.projectRoot": "Raíz del proyecto",
|
||||
"chat.chatInput.branch": "Rama",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"projectActions.actions.addActionAria": "Añadir acción",
|
||||
"projectActions.actions.addAction": "Añadir acción",
|
||||
"projectActions.actions.addNewAction": "Añadir nueva acción",
|
||||
"projectActions.actions.autoDiscover": "Autodetectar",
|
||||
"projectActions.actions.chooseActionAria": "Elegir acción del proyecto",
|
||||
"projectActions.actions.openPreview": "Abrir Preview",
|
||||
"projectActions.actions.runNamedAria": "Ejecutar {name}",
|
||||
"projectActions.actions.stopNamedAria": "Detener {name}",
|
||||
"projectActions.label.fallbackAction": "Acción",
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Copiar fuente",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Descargar SVG",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "No se pudo descargar el diagrama",
|
||||
"contextPanel.preview.title": "Vista previa",
|
||||
"contextPanel.preview.description": "Usa Acciones del proyecto o el botón Preview del terminal para abrir una vista previa.",
|
||||
"contextPanel.preview.startPreview": "Iniciar vista previa",
|
||||
"contextPanel.preview.starting": "Iniciando...",
|
||||
"contextPanel.preview.noDevServer": "No se encontro un comando de servidor de desarrollo. Configura una accion del proyecto o anade un script \"dev\" a package.json.",
|
||||
"contextPanel.preview.startFailed": "Fallo al iniciar el servidor de vista previa.",
|
||||
"contextPanel.preview.serverExited": "El servidor de desarrollo se cerro inesperadamente.",
|
||||
"contextPanel.preview.noUrlDetected": "El servidor de desarrollo se inicio, pero no se detecto ningun URL en su salida. Abre la pestaña de terminal de vista previa para ver los registros.",
|
||||
"contextPanel.preview.serverExitedWithLog": "El servidor de desarrollo terminó inesperadamente. Última salida:\n\n{log}",
|
||||
"contextPanel.preview.noUrlDetectedWithLog": "El servidor de desarrollo no respondió. Última salida:\n\n{log}",
|
||||
"contextPanel.preview.startingServer": "Iniciando servidor de desarrollo...",
|
||||
"contextPanel.preview.startingServerHint": "Esperando a que el servidor acepte conexiones.",
|
||||
"contextPanel.preview.console.open": "Abrir consola de vista previa",
|
||||
"contextPanel.preview.console.waiting": "Esperando la consola de vista previa",
|
||||
"contextPanel.preview.console.title": "Consola de vista previa",
|
||||
"contextPanel.preview.console.attach": "Adjuntar",
|
||||
"contextPanel.preview.console.copy": "Copiar",
|
||||
"contextPanel.preview.console.clear": "Limpiar",
|
||||
"contextPanel.preview.console.empty": "Aún no hay eventos de consola de vista previa.",
|
||||
"contextPanel.preview.console.noFilteredEvents": "Ningún evento coincide con este filtro.",
|
||||
"contextPanel.preview.console.runtimeError": "Error de ejecución",
|
||||
"contextPanel.preview.console.copied": "Consola de vista previa copiada",
|
||||
"contextPanel.preview.console.copyFailed": "No se pudo copiar la consola de vista previa",
|
||||
"contextPanel.preview.console.attached": "Consola de vista previa adjuntada al chat",
|
||||
"contextPanel.preview.console.attachNoSession": "Abre una sesión de chat antes de adjuntar logs de vista previa",
|
||||
"contextPanel.preview.console.attachAnnotation": "Estos son logs de la consola del navegador del servidor de desarrollo que se ejecuta para este proyecto.",
|
||||
"contextPanel.preview.inspect.toggle": "Inspeccionar elemento de vista previa",
|
||||
"contextPanel.preview.inspect.attached": "Anotación de vista previa adjuntada al chat",
|
||||
"contextPanel.preview.inspect.attachNoSession": "Abre una sesión de chat antes de adjuntar anotaciones de vista previa",
|
||||
"contextPanel.preview.inspect.attachAnnotation": "Este es un elemento DOM seleccionado de la vista previa integrada.",
|
||||
"contextPanel.preview.inspect.attachAnnotationWithScreenshot": "Este es un elemento DOM seleccionado de la vista previa integrada. Se adjunta una captura del área visible de la vista previa con el elemento resaltado.",
|
||||
"contextPanel.preview.console.filter.all": "Todo",
|
||||
"contextPanel.preview.console.filter.errors": "Errores",
|
||||
"contextPanel.preview.console.filter.warnings": "Advertencias",
|
||||
"contextPanel.preview.console.filter.logs": "Registros",
|
||||
};
|
||||
|
||||
@@ -696,6 +696,57 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.diff': 'diff',
|
||||
'contextPanel.mode.plan': '플랜',
|
||||
'contextPanel.mode.context': '컨텍스트',
|
||||
'contextPanel.mode.preview': '미리보기',
|
||||
'contextPanel.preview.actions.reload': '미리보기 새로고침',
|
||||
'contextPanel.preview.actions.openExternal': '브라우저에서 열기',
|
||||
'contextPanel.preview.actions.retry': '다시 시도',
|
||||
'contextPanel.preview.iframeTitle': '미리보기',
|
||||
'contextPanel.preview.invalidUrl': '미리보기에는 유효한 http(s) URL이 필요합니다.',
|
||||
'contextPanel.preview.empty': '미리보기 URL이 없습니다',
|
||||
'contextPanel.preview.loading': '미리보기 프록시에 연결 중...',
|
||||
'contextPanel.preview.proxyError': '미리보기 프록시를 시작할 수 없습니다.',
|
||||
'contextPanel.preview.upstreamUnreachable': '개발 서버가 응답하지 않습니다.',
|
||||
'contextPanel.preview.upstreamUnreachableHint': '개발 서버가 여전히 실행 중인지 확인한 후 다시 시도하세요.',
|
||||
'contextPanel.preview.startingServer': '개발 서버를 시작하는 중...',
|
||||
'contextPanel.preview.startingServerHint': '서버가 연결을 수락할 때까지 기다리는 중입니다.',
|
||||
'contextPanel.preview.title': '미리보기',
|
||||
'contextPanel.preview.description': '프로젝트 작업 또는 터미널 Preview 버튼으로 미리보기를 여세요.',
|
||||
'contextPanel.preview.startPreview': '미리보기 시작',
|
||||
'contextPanel.preview.starting': '시작 중...',
|
||||
'contextPanel.preview.noDevServer': '개발 서버 명령을 찾을 수 없습니다. 프로젝트 동작을 구성하거나 package.json에 "dev" 스크립트를 추가하세요.',
|
||||
'contextPanel.preview.startFailed': '미리보기 서버를 시작하지 못했습니다.',
|
||||
'contextPanel.preview.serverExited': '개발 서버가 예기치 않게 종료되었습니다.',
|
||||
'contextPanel.preview.noUrlDetected': '개발 서버가 시작되었지만 출력에서 URL이 감지되지 않았습니다. 미리보기 터미널 탭에서 로그를 확인하세요.',
|
||||
'contextPanel.preview.serverExitedWithLog': '개발 서버가 예기치 않게 종료되었습니다. 마지막 출력:\n\n{log}',
|
||||
'contextPanel.preview.noUrlDetectedWithLog': '개발 서버가 응답할 수 있는 상태가 되지 못했습니다. 마지막 출력:\n\n{log}',
|
||||
'contextPanel.preview.console.open': '미리보기 콘솔 열기',
|
||||
'contextPanel.preview.console.waiting': '미리보기 콘솔 대기 중',
|
||||
'contextPanel.preview.console.title': '미리보기 콘솔',
|
||||
'contextPanel.preview.console.attach': '첨부',
|
||||
'contextPanel.preview.console.copy': '복사',
|
||||
'contextPanel.preview.console.clear': '지우기',
|
||||
'contextPanel.preview.console.empty': '아직 미리보기 콘솔 이벤트가 없습니다.',
|
||||
'contextPanel.preview.console.noFilteredEvents': '이 필터와 일치하는 이벤트가 없습니다.',
|
||||
'contextPanel.preview.console.runtimeError': '런타임 오류',
|
||||
'contextPanel.preview.console.copied': '미리보기 콘솔 복사됨',
|
||||
'contextPanel.preview.console.copyFailed': '미리보기 콘솔 복사 실패',
|
||||
'contextPanel.preview.console.attached': '미리보기 콘솔이 채팅에 첨부되었습니다',
|
||||
'contextPanel.preview.console.attachNoSession': '미리보기 로그를 첨부하기 전에 채팅 세션을 여세요',
|
||||
'contextPanel.preview.console.attachAnnotation': '이것은 이 프로젝트에서 실행 중인 개발 서버의 브라우저 콘솔 로그입니다.',
|
||||
'contextPanel.preview.inspect.toggle': '미리보기 요소 검사',
|
||||
'contextPanel.preview.inspect.attached': '미리보기 주석이 채팅에 첨부되었습니다',
|
||||
'contextPanel.preview.inspect.attachNoSession': '미리보기 주석을 첨부하기 전에 채팅 세션을 여세요',
|
||||
'contextPanel.preview.inspect.attachAnnotation': '인앱 미리보기에서 선택한 DOM 요소입니다.',
|
||||
'contextPanel.preview.inspect.attachAnnotationWithScreenshot': '인앱 미리보기에서 선택한 DOM 요소입니다. 선택한 요소가 강조된 보이는 미리보기 영역 스크린샷이 첨부되었습니다.',
|
||||
'contextPanel.preview.console.filter.all': '전체',
|
||||
'contextPanel.preview.console.filter.errors': '오류',
|
||||
'contextPanel.preview.console.filter.warnings': '경고',
|
||||
'contextPanel.preview.console.filter.logs': '로그',
|
||||
'terminalView.preview.open': '미리보기',
|
||||
'terminalView.preview.openTitle': '미리보기 패널 열기',
|
||||
'header.services.shutdownDev': 'OpenChamber 종료',
|
||||
'chat.messageBody.actions.openPreviewAria': '미리보기 열기',
|
||||
'chat.messageBody.actions.openPreview': '미리보기 열기',
|
||||
'contextPanel.tab.closeTabAria': '{label} tab 닫기',
|
||||
'contextPanel.actions.collapsePanel': '접기 패널',
|
||||
'contextPanel.actions.expandPanel': '펼치기 패널',
|
||||
@@ -1383,6 +1434,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.openSessionFirst': '먼저 세션을 여세요',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'toggle permission auto-accept 실패',
|
||||
'chat.chatInput.reviewComments': 'Review 댓글:',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 로그:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Dev Server 로그 제거',
|
||||
'chat.chatInput.previewAnnotations': '미리보기 주석:',
|
||||
'chat.chatInput.previewContext': '미리보기 컨텍스트:',
|
||||
'chat.chatInput.previewContextRemove': '미리보기 컨텍스트 제거',
|
||||
'chat.chatInput.projectRoot': '프로젝트 root',
|
||||
'chat.chatInput.branch': '브랜치',
|
||||
'chat.chatInput.worktrees': '워크트리',
|
||||
@@ -1763,7 +1819,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'projectActions.actions.addActionAria': 'action 추가',
|
||||
'projectActions.actions.addAction': 'action 추가',
|
||||
'projectActions.actions.addNewAction': 'new action 추가',
|
||||
'projectActions.actions.autoDiscover': '자동 검색',
|
||||
'projectActions.actions.chooseActionAria': '선택 프로젝트 작업',
|
||||
'projectActions.actions.openPreview': 'Preview 열기',
|
||||
'projectActions.actions.runNamedAria': '실행 {name}',
|
||||
'projectActions.actions.stopNamedAria': '중지 {name}',
|
||||
'projectActions.label.fallbackAction': '작업',
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.diff": "Diff",
|
||||
"contextPanel.mode.plan": "Plano",
|
||||
"contextPanel.mode.context": "Contexto",
|
||||
"contextPanel.mode.preview": "Prévia",
|
||||
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
|
||||
"contextPanel.actions.collapsePanel": "Recolher painel",
|
||||
"contextPanel.actions.expandPanel": "Expandir painel",
|
||||
"contextPanel.actions.closePanel": "Fechar painel",
|
||||
"contextPanel.actions.resizePanelAria": "Ajustar tamanho do painel de contexto",
|
||||
"contextPanel.iframe.sessionChatTitle": "Chat de sessão {sessionID}",
|
||||
"contextPanel.preview.actions.reload": "Recarregar prévia",
|
||||
"contextPanel.preview.actions.openExternal": "Abrir no navegador",
|
||||
"contextPanel.preview.actions.retry": "Tentar novamente",
|
||||
"contextPanel.preview.iframeTitle": "Prévia",
|
||||
"contextPanel.preview.invalidUrl": "A prévia precisa de uma URL http(s) válida.",
|
||||
"contextPanel.preview.empty": "Sem URL de prévia",
|
||||
"contextPanel.preview.loading": "Conectando proxy de prévia...",
|
||||
"contextPanel.preview.proxyError": "Não foi possível iniciar o proxy de prévia.",
|
||||
"contextPanel.preview.upstreamUnreachable": "O servidor de desenvolvimento não está respondendo.",
|
||||
"contextPanel.preview.upstreamUnreachableHint": "Verifique se o servidor de desenvolvimento ainda está em execução e tente novamente.",
|
||||
|
||||
"terminalView.preview.open": "Prévia",
|
||||
"terminalView.preview.openTitle": "Abrir painel de prévia",
|
||||
"sidebarFilesTree.menu.rename": "Renomear",
|
||||
"sidebarFilesTree.menu.copyPath": "Copiar caminho",
|
||||
"sidebarFilesTree.menu.save": "Salvar",
|
||||
@@ -959,6 +973,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.services.used": "Usado",
|
||||
"header.services.remaining": "Restante",
|
||||
"header.services.modelFamily.other": "Outro",
|
||||
"header.services.shutdownDev": "Parar OpenChamber",
|
||||
"header.actions.openPlanAria": "Abrir plano",
|
||||
"header.actions.planWithShortcut": "Plano ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Bifurcar daqui",
|
||||
"chat.messageBody.actions.copyMessageAria": "Copiar texto da mensagem",
|
||||
"chat.messageBody.actions.copyMessage": "Copiar mensagem",
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir visualização",
|
||||
"chat.messageBody.actions.openPreview": "Abrir visualização",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar resposta",
|
||||
"chat.messageBody.actions.savingImage": "Salvando imagem...",
|
||||
"chat.messageBody.actions.saveAsImage": "Salvar como imagem",
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.openSessionFirst": "Abra uma sessão primeiro",
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Não foi possível alterar a aceitação automática de permissões",
|
||||
"chat.chatInput.reviewComments": "Comentários de revisão:",
|
||||
"chat.chatInput.devServerLogs": "Logs do Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Remover logs do Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Anotações da visualização:",
|
||||
"chat.chatInput.previewContext": "Contexto da visualização:",
|
||||
"chat.chatInput.previewContextRemove": "Remover contexto da visualização",
|
||||
"chat.chatInput.projectRoot": "Raiz do projeto",
|
||||
"chat.chatInput.branch": "Branch",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"projectActions.actions.addActionAria": "Adicionar ação",
|
||||
"projectActions.actions.addAction": "Adicionar ação",
|
||||
"projectActions.actions.addNewAction": "Adicionar nova ação",
|
||||
"projectActions.actions.autoDiscover": "Detectar automaticamente",
|
||||
"projectActions.actions.chooseActionAria": "Escolher ação do projeto",
|
||||
"projectActions.actions.openPreview": "Abrir Preview",
|
||||
"projectActions.actions.runNamedAria": "Executar {name}",
|
||||
"projectActions.actions.stopNamedAria": "Parar {name}",
|
||||
"projectActions.label.fallbackAction": "Ação",
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Copiar origem",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Baixar SVG",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "Não foi possível baixar o diagrama",
|
||||
"contextPanel.preview.title": "Visualização",
|
||||
"contextPanel.preview.description": "Use Ações do projeto ou o botão Preview do terminal para abrir uma visualização.",
|
||||
"contextPanel.preview.startPreview": "Iniciar visualização",
|
||||
"contextPanel.preview.starting": "Iniciando...",
|
||||
"contextPanel.preview.noDevServer": "Nenhum comando de servidor de desenvolvimento encontrado. Configure uma ação do projeto ou adicione um script \"dev\" ao package.json.",
|
||||
"contextPanel.preview.startFailed": "Falha ao iniciar o servidor de visualização.",
|
||||
"contextPanel.preview.serverExited": "Servidor de desenvolvimento encerrou inesperadamente.",
|
||||
"contextPanel.preview.noUrlDetected": "O servidor de desenvolvimento foi iniciado, mas nenhum URL foi detectado na saída. Abra a aba de terminal da visualização para ver os logs.",
|
||||
"contextPanel.preview.serverExitedWithLog": "O servidor de desenvolvimento encerrou inesperadamente. Última saída:\n\n{log}",
|
||||
"contextPanel.preview.noUrlDetectedWithLog": "O servidor de desenvolvimento não respondeu. Última saída:\n\n{log}",
|
||||
"contextPanel.preview.startingServer": "Iniciando servidor de desenvolvimento...",
|
||||
"contextPanel.preview.startingServerHint": "Aguardando o servidor aceitar conexões.",
|
||||
"contextPanel.preview.console.open": "Abrir console da visualização",
|
||||
"contextPanel.preview.console.waiting": "Aguardando console da visualização",
|
||||
"contextPanel.preview.console.title": "Console da visualização",
|
||||
"contextPanel.preview.console.attach": "Anexar",
|
||||
"contextPanel.preview.console.copy": "Copiar",
|
||||
"contextPanel.preview.console.clear": "Limpar",
|
||||
"contextPanel.preview.console.empty": "Ainda não há eventos do console da visualização.",
|
||||
"contextPanel.preview.console.noFilteredEvents": "Nenhum evento corresponde a este filtro.",
|
||||
"contextPanel.preview.console.runtimeError": "Erro de execução",
|
||||
"contextPanel.preview.console.copied": "Console da visualização copiado",
|
||||
"contextPanel.preview.console.copyFailed": "Não foi possível copiar o console da visualização",
|
||||
"contextPanel.preview.console.attached": "Console da visualização anexado ao chat",
|
||||
"contextPanel.preview.console.attachNoSession": "Abra uma sessão de chat antes de anexar logs da visualização",
|
||||
"contextPanel.preview.console.attachAnnotation": "Estes são logs do console do navegador do servidor de desenvolvimento em execução para este projeto.",
|
||||
"contextPanel.preview.inspect.toggle": "Inspecionar elemento da visualização",
|
||||
"contextPanel.preview.inspect.attached": "Anotação da visualização anexada ao chat",
|
||||
"contextPanel.preview.inspect.attachNoSession": "Abra uma sessão de chat antes de anexar anotações da visualização",
|
||||
"contextPanel.preview.inspect.attachAnnotation": "Este é um elemento DOM selecionado da visualização integrada.",
|
||||
"contextPanel.preview.inspect.attachAnnotationWithScreenshot": "Este é um elemento DOM selecionado da visualização integrada. Uma captura da área visível da visualização com o elemento destacado foi anexada.",
|
||||
"contextPanel.preview.console.filter.all": "Tudo",
|
||||
"contextPanel.preview.console.filter.errors": "Erros",
|
||||
"contextPanel.preview.console.filter.warnings": "Avisos",
|
||||
"contextPanel.preview.console.filter.logs": "Logs",
|
||||
};
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.diff": "Diff",
|
||||
"contextPanel.mode.plan": "План",
|
||||
"contextPanel.mode.context": "Контекст",
|
||||
"contextPanel.mode.preview": "Перегляд",
|
||||
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
|
||||
"contextPanel.actions.collapsePanel": "Згорнути панель",
|
||||
"contextPanel.actions.expandPanel": "Розгорнути панель",
|
||||
"contextPanel.actions.closePanel": "Закрити панель",
|
||||
"contextPanel.actions.resizePanelAria": "Змінити розмір контекстної панелі",
|
||||
"contextPanel.iframe.sessionChatTitle": "Сесійний чат {sessionID}",
|
||||
"contextPanel.preview.actions.reload": "Перезавантажити перегляд",
|
||||
"contextPanel.preview.actions.openExternal": "Відкрити в браузері",
|
||||
"contextPanel.preview.actions.retry": "Повторити",
|
||||
"contextPanel.preview.iframeTitle": "Перегляд",
|
||||
"contextPanel.preview.invalidUrl": "Для перегляду потрібна коректна URL http(s).",
|
||||
"contextPanel.preview.empty": "Немає URL для перегляду",
|
||||
"contextPanel.preview.loading": "Підключення проксі перегляду...",
|
||||
"contextPanel.preview.proxyError": "Не вдалося запустити проксі перегляду.",
|
||||
"contextPanel.preview.upstreamUnreachable": "Сервер розробки не відповідає.",
|
||||
"contextPanel.preview.upstreamUnreachableHint": "Переконайтеся, що сервер розробки все ще працює, і повторіть спробу.",
|
||||
|
||||
"terminalView.preview.open": "Перегляд",
|
||||
"terminalView.preview.openTitle": "Відкрити панель перегляду",
|
||||
"sidebarFilesTree.menu.rename": "Перейменувати",
|
||||
"sidebarFilesTree.menu.copyPath": "Копіювати шлях",
|
||||
"sidebarFilesTree.menu.save": "Зберегти",
|
||||
@@ -958,6 +972,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.services.noRateLimitsReported": "Ліміти запитів не надходять.",
|
||||
"header.services.used": "Використано",
|
||||
"header.services.remaining": "Залишилося",
|
||||
"header.services.shutdownDev": "Зупинити OpenChamber",
|
||||
"header.services.modelFamily.other": "інше",
|
||||
"header.actions.openPlanAria": "Відкрити план",
|
||||
"header.actions.planWithShortcut": "План ({shortcut})",
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Відгалузити звідси",
|
||||
"chat.messageBody.actions.copyMessageAria": "Копіювати текст повідомлення",
|
||||
"chat.messageBody.actions.copyMessage": "Копіювати повідомлення",
|
||||
"chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.openPreview": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.copyAnswer": "Скопіювати відповідь",
|
||||
"chat.messageBody.actions.savingImage": "Збереження зображення...",
|
||||
"chat.messageBody.actions.saveAsImage": "Зберегти як зображення",
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.openSessionFirst": "Спочатку відкрийте сесію",
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Не вдалося ввімкнути автоматичне прийняття дозволів",
|
||||
"chat.chatInput.reviewComments": "Коментарі рев’ю:",
|
||||
"chat.chatInput.devServerLogs": "Логи Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Прибрати логи Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Анотації перегляду:",
|
||||
"chat.chatInput.previewContext": "Контекст перегляду:",
|
||||
"chat.chatInput.previewContextRemove": "Прибрати контекст перегляду",
|
||||
"chat.chatInput.projectRoot": "Корінь проєкту",
|
||||
"chat.chatInput.branch": "гілка",
|
||||
"chat.chatInput.worktrees": "Worktree",
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"projectActions.actions.addActionAria": "Додати дію",
|
||||
"projectActions.actions.addAction": "Додати дію",
|
||||
"projectActions.actions.addNewAction": "Додати нову дію",
|
||||
"projectActions.actions.autoDiscover": "Автовиявлення",
|
||||
"projectActions.actions.chooseActionAria": "Вибрати дію проєкту",
|
||||
"projectActions.actions.openPreview": "Відкрити Preview",
|
||||
"projectActions.actions.runNamedAria": "Запустити {name}",
|
||||
"projectActions.actions.stopNamedAria": "Зупинити {name}",
|
||||
"projectActions.label.fallbackAction": "Дія",
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Копіювати джерело",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Завантажити SVG",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "Не вдалося завантажити діаграму",
|
||||
"contextPanel.preview.title": "Попередній перегляд",
|
||||
"contextPanel.preview.description": "Використайте дії проєкту або кнопку Preview у терміналі, щоб відкрити перегляд.",
|
||||
"contextPanel.preview.startPreview": "Почати перегляд",
|
||||
"contextPanel.preview.starting": "Запуск...",
|
||||
"contextPanel.preview.noDevServer": "Команду сервера розробки не знайдено. Налаштуйте дію проекту або додайте скрипт \"dev\" у package.json.",
|
||||
"contextPanel.preview.startFailed": "Не вдалося запустити сервер перегляду.",
|
||||
"contextPanel.preview.serverExited": "Сервер розробки несподівано завершив роботу.",
|
||||
"contextPanel.preview.noUrlDetected": "Сервер розробки запущено, але URL не виявлено у його виводі. Відкрийте вкладку термінала попереднього перегляду, щоб переглянути журнали.",
|
||||
"contextPanel.preview.serverExitedWithLog": "Сервер розробки несподівано завершив роботу. Останній вивід:\n\n{log}",
|
||||
"contextPanel.preview.noUrlDetectedWithLog": "Сервер розробки не став доступним. Останній вивід:\n\n{log}",
|
||||
"contextPanel.preview.startingServer": "Запуск сервера розробки...",
|
||||
"contextPanel.preview.startingServerHint": "Очікуємо, поки сервер почне приймати з'єднання.",
|
||||
"contextPanel.preview.console.open": "Відкрити консоль перегляду",
|
||||
"contextPanel.preview.console.waiting": "Очікування консолі перегляду",
|
||||
"contextPanel.preview.console.title": "Консоль перегляду",
|
||||
"contextPanel.preview.console.attach": "Додати",
|
||||
"contextPanel.preview.console.copy": "Копіювати",
|
||||
"contextPanel.preview.console.clear": "Очистити",
|
||||
"contextPanel.preview.console.empty": "Подій консолі перегляду ще немає.",
|
||||
"contextPanel.preview.console.noFilteredEvents": "Немає подій для цього фільтра.",
|
||||
"contextPanel.preview.console.runtimeError": "Помилка виконання",
|
||||
"contextPanel.preview.console.copied": "Консоль перегляду скопійовано",
|
||||
"contextPanel.preview.console.copyFailed": "Не вдалося скопіювати консоль перегляду",
|
||||
"contextPanel.preview.console.attached": "Консоль перегляду додано до чату",
|
||||
"contextPanel.preview.console.attachNoSession": "Відкрийте чат-сесію перед додаванням логів перегляду",
|
||||
"contextPanel.preview.console.attachAnnotation": "Це браузерні console logs із dev server, що запущений для цього проєкту.",
|
||||
"contextPanel.preview.inspect.toggle": "Інспектувати елемент перегляду",
|
||||
"contextPanel.preview.inspect.attached": "Анотацію перегляду додано до чату",
|
||||
"contextPanel.preview.inspect.attachNoSession": "Відкрийте чат-сесію перед додаванням анотацій перегляду",
|
||||
"contextPanel.preview.inspect.attachAnnotation": "Це вибраний DOM-елемент із вбудованого перегляду.",
|
||||
"contextPanel.preview.inspect.attachAnnotationWithScreenshot": "Це вибраний DOM-елемент із вбудованого перегляду. Скріншот видимої області перегляду з підсвіченим елементом додано як вкладення.",
|
||||
"contextPanel.preview.console.filter.all": "Усі",
|
||||
"contextPanel.preview.console.filter.errors": "Помилки",
|
||||
"contextPanel.preview.console.filter.warnings": "Попередження",
|
||||
"contextPanel.preview.console.filter.logs": "Логи",
|
||||
};
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.diff': '对比',
|
||||
'contextPanel.mode.plan': '计划',
|
||||
'contextPanel.mode.context': '上下文',
|
||||
'contextPanel.mode.preview': '预览',
|
||||
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
|
||||
'contextPanel.actions.collapsePanel': '折叠面板',
|
||||
'contextPanel.actions.expandPanel': '展开面板',
|
||||
'contextPanel.actions.closePanel': '关闭面板',
|
||||
'contextPanel.actions.resizePanelAria': '调整上下文面板大小',
|
||||
'contextPanel.iframe.sessionChatTitle': '会话聊天 {sessionID}',
|
||||
'contextPanel.preview.actions.reload': '刷新预览',
|
||||
'contextPanel.preview.actions.openExternal': '在浏览器中打开',
|
||||
'contextPanel.preview.actions.retry': '重试',
|
||||
'contextPanel.preview.iframeTitle': '预览',
|
||||
'contextPanel.preview.invalidUrl': '预览需要有效的 http(s) URL。',
|
||||
'contextPanel.preview.empty': '没有预览 URL',
|
||||
'contextPanel.preview.loading': '正在连接预览代理...',
|
||||
'contextPanel.preview.proxyError': '无法启动预览代理。',
|
||||
'contextPanel.preview.upstreamUnreachable': '开发服务器无响应。',
|
||||
'contextPanel.preview.upstreamUnreachableHint': '请确认开发服务器仍在运行,然后重试。',
|
||||
|
||||
'terminalView.preview.open': '预览',
|
||||
'terminalView.preview.openTitle': '打开预览面板',
|
||||
'sidebarFilesTree.menu.rename': '重命名',
|
||||
'sidebarFilesTree.menu.copyPath': '复制路径',
|
||||
'sidebarFilesTree.menu.save': '保存',
|
||||
@@ -958,6 +972,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'header.services.noRateLimitsReported': '未上报速率限制。',
|
||||
'header.services.used': '已用',
|
||||
'header.services.remaining': '剩余',
|
||||
'header.services.shutdownDev': '停止 OpenChamber',
|
||||
'header.services.modelFamily.other': '其他',
|
||||
'header.actions.openPlanAria': '打开计划',
|
||||
'header.actions.planWithShortcut': '计划({shortcut})',
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': '从此处分叉',
|
||||
'chat.messageBody.actions.copyMessageAria': '复制消息文本',
|
||||
'chat.messageBody.actions.copyMessage': '复制消息',
|
||||
'chat.messageBody.actions.openPreviewAria': '打开预览',
|
||||
'chat.messageBody.actions.openPreview': '打开预览',
|
||||
'chat.messageBody.actions.copyAnswer': '复制回答',
|
||||
'chat.messageBody.actions.savingImage': '正在保存图片...',
|
||||
'chat.messageBody.actions.saveAsImage': '保存为图片',
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.openSessionFirst': '请先打开一个会话',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切换权限自动接受失败',
|
||||
'chat.chatInput.reviewComments': '审查评论:',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 日志:',
|
||||
'chat.chatInput.devServerLogsRemove': '移除 Dev Server 日志',
|
||||
'chat.chatInput.previewAnnotations': '预览注释:',
|
||||
'chat.chatInput.previewContext': '预览上下文:',
|
||||
'chat.chatInput.previewContextRemove': '移除预览上下文',
|
||||
'chat.chatInput.projectRoot': '项目根目录',
|
||||
'chat.chatInput.branch': '分支',
|
||||
'chat.chatInput.worktrees': '工作树',
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'projectActions.actions.addActionAria': '添加操作',
|
||||
'projectActions.actions.addAction': '添加操作',
|
||||
'projectActions.actions.addNewAction': '添加新操作',
|
||||
'projectActions.actions.autoDiscover': '自动发现',
|
||||
'projectActions.actions.chooseActionAria': '选择项目操作',
|
||||
'projectActions.actions.openPreview': '打开 Preview',
|
||||
'projectActions.actions.runNamedAria': '运行 {name}',
|
||||
'projectActions.actions.stopNamedAria': '停止 {name}',
|
||||
'projectActions.label.fallbackAction': '操作',
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': '复制源码',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': '下载 SVG',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': '下载图表失败',
|
||||
'contextPanel.preview.title': '预览',
|
||||
'contextPanel.preview.description': '使用项目操作或终端 Preview 按钮打开预览。',
|
||||
'contextPanel.preview.startPreview': '启动预览',
|
||||
'contextPanel.preview.starting': '正在启动...',
|
||||
'contextPanel.preview.noDevServer': '未找到开发服务器命令。请配置项目操作或添加 "dev" 脚本到 package.json。',
|
||||
'contextPanel.preview.startFailed': '启动预览服务器失败。',
|
||||
'contextPanel.preview.serverExited': '开发服务器意外退出。',
|
||||
'contextPanel.preview.noUrlDetected': '开发服务器已启动,但未在输出中检测到 URL。请打开预览终端标签页查看日志。',
|
||||
'contextPanel.preview.serverExitedWithLog': '开发服务器意外退出。最后输出:\n\n{log}',
|
||||
'contextPanel.preview.noUrlDetectedWithLog': '开发服务器未能响应。最后输出:\n\n{log}',
|
||||
'contextPanel.preview.startingServer': '正在启动开发服务器...',
|
||||
'contextPanel.preview.startingServerHint': '正在等待服务器开始接受连接。',
|
||||
'contextPanel.preview.console.open': '打开预览控制台',
|
||||
'contextPanel.preview.console.waiting': '正在等待预览控制台',
|
||||
'contextPanel.preview.console.title': '预览控制台',
|
||||
'contextPanel.preview.console.attach': '附加',
|
||||
'contextPanel.preview.console.copy': '复制',
|
||||
'contextPanel.preview.console.clear': '清除',
|
||||
'contextPanel.preview.console.empty': '还没有预览控制台事件。',
|
||||
'contextPanel.preview.console.noFilteredEvents': '没有匹配此筛选器的事件。',
|
||||
'contextPanel.preview.console.runtimeError': '运行时错误',
|
||||
'contextPanel.preview.console.copied': '预览控制台已复制',
|
||||
'contextPanel.preview.console.copyFailed': '无法复制预览控制台',
|
||||
'contextPanel.preview.console.attached': '预览控制台已附加到聊天',
|
||||
'contextPanel.preview.console.attachNoSession': '请先打开聊天会话,再附加预览日志',
|
||||
'contextPanel.preview.console.attachAnnotation': '这些是此项目开发服务器的浏览器控制台日志。',
|
||||
'contextPanel.preview.inspect.toggle': '检查预览元素',
|
||||
'contextPanel.preview.inspect.attached': '预览注释已附加到聊天',
|
||||
'contextPanel.preview.inspect.attachNoSession': '请先打开聊天会话,再附加预览注释',
|
||||
'contextPanel.preview.inspect.attachAnnotation': '这是内置预览中选中的 DOM 元素。',
|
||||
'contextPanel.preview.inspect.attachAnnotationWithScreenshot': '这是内置预览中选中的 DOM 元素。已附加带有高亮选中元素的可见预览区域截图。',
|
||||
'contextPanel.preview.console.filter.all': '全部',
|
||||
'contextPanel.preview.console.filter.errors': '错误',
|
||||
'contextPanel.preview.console.filter.warnings': '警告',
|
||||
'contextPanel.preview.console.filter.logs': '日志',
|
||||
};
|
||||
|
||||
@@ -11,6 +11,14 @@ export function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
if (draft.source === 'diff' && side) {
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-console') {
|
||||
return `Attached preview context from \`${fileLabel}\`:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-annotation') {
|
||||
return text ? `${code}\n\n${text}` : code;
|
||||
}
|
||||
|
||||
// Plan and file format (no side)
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
@@ -22,7 +30,11 @@ export function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
*/
|
||||
export function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return '';
|
||||
|
||||
|
||||
if (drafts.every((draft) => draft.source === 'preview-annotation')) {
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,12 @@ const readTextFile = async (path: string): Promise<string | null> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`);
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -201,7 +206,10 @@ const resolveHomeDirectory = async (): Promise<string | null> => {
|
||||
// In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root
|
||||
// scoped, which would incorrectly route writes into the project directory.
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/fs/home`);
|
||||
const response = await fetch(`${getBaseUrl()}/fs/home`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to resolve home directory from API');
|
||||
}
|
||||
|
||||
@@ -28,6 +28,57 @@ export const isExternalHttpUrl = (url: string): boolean => {
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
};
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
|
||||
|
||||
/**
|
||||
* Returns true when the URL is an http(s) URL pointing at a loopback host
|
||||
* (localhost, 127.0.0.1, 0.0.0.0, ::1). Used to decide whether to offer an in-app
|
||||
* preview pane instead of opening the system browser.
|
||||
*/
|
||||
export const isLoopbackHttpUrl = (url: string): boolean => {
|
||||
const parsed = parseUrlSafely(url.trim());
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
||||
};
|
||||
|
||||
const LOOPBACK_URL_PATTERN
|
||||
// eslint-disable-next-line no-control-regex
|
||||
= /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d{2,5})?(?:\/[^\s<>"'`\u0000-\u001f]*)?/gi;
|
||||
|
||||
/**
|
||||
* Extracts loopback http(s) URLs from a free-text string. Returns unique URLs
|
||||
* in order of first appearance. Trailing punctuation that is unlikely to be
|
||||
* part of a real URL is stripped.
|
||||
*/
|
||||
export const extractLoopbackUrls = (text: string): string[] => {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const matches = text.match(LOOPBACK_URL_PATTERN);
|
||||
if (!matches || matches.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of matches) {
|
||||
const cleaned = raw.replace(/[),.;:!?'"`]+$/g, '');
|
||||
if (!cleaned || !isLoopbackHttpUrl(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(cleaned);
|
||||
out.push(cleaned);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an external URL in the system browser.
|
||||
* In Tauri desktop runtime, uses tauri.shell.open() for proper handling.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file';
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
|
||||
|
||||
export interface InlineCommentDraft {
|
||||
id: string;
|
||||
@@ -36,7 +36,7 @@ interface InlineCommentDraftActions {
|
||||
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
|
||||
|
||||
const isValidSource = (value: unknown): value is InlineCommentSource =>
|
||||
value === 'diff' || value === 'plan' || value === 'file';
|
||||
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation';
|
||||
|
||||
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
|
||||
value === 'original' || value === 'modified';
|
||||
|
||||
@@ -16,10 +16,14 @@ export type TerminalTab = {
|
||||
terminalSessionId: string | null;
|
||||
lifecycle: TerminalTabLifecycle;
|
||||
label: string;
|
||||
iconKey: string | null;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
isConnecting: boolean;
|
||||
createdAt: number;
|
||||
previewUrl: string | null;
|
||||
previewAutoOpened: boolean;
|
||||
previewUrlLocked: boolean;
|
||||
};
|
||||
|
||||
export type DirectoryTerminalState = {
|
||||
@@ -27,8 +31,18 @@ export type DirectoryTerminalState = {
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
export type TerminalProjectActionRun = {
|
||||
key: string;
|
||||
directory: string;
|
||||
actionId: string;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
status: 'running' | 'waiting-for-preview' | 'stopping';
|
||||
};
|
||||
|
||||
interface TerminalStore {
|
||||
sessions: Map<string, DirectoryTerminalState>;
|
||||
projectActionRuns: Record<string, TerminalProjectActionRun>;
|
||||
nextChunkId: number;
|
||||
nextTabId: number;
|
||||
hasHydrated: boolean;
|
||||
@@ -40,6 +54,7 @@ interface TerminalStore {
|
||||
createTab: (directory: string) => string;
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
@@ -47,6 +62,11 @@ interface TerminalStore {
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean }) => void;
|
||||
markPreviewAutoOpened: (directory: string, tabId: string) => void;
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => void;
|
||||
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => void;
|
||||
removeProjectActionRun: (runKey: string) => void;
|
||||
|
||||
removeDirectory: (directory: string) => void;
|
||||
clearAll: () => void;
|
||||
@@ -56,7 +76,7 @@ const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
@@ -91,12 +111,77 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
label,
|
||||
iconKey: null,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
createdAt: Date.now(),
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
previewUrlLocked: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
|
||||
// Many dev servers print loopback as 0.0.0.0, localhost, or IPv6 ([::]/[::1]).
|
||||
const URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[\w\-./~%!$&'()*+,;=:@?#[\]]*)?)/i;
|
||||
|
||||
// Dev server logs frequently wrap URLs in punctuation, e.g.
|
||||
// "Local: http://localhost:5173/ (press h to show help)"
|
||||
// "Serving on (http://127.0.0.1:50028/)."
|
||||
// The URL_PATTERN above intentionally allows sub-delim characters like `()`
|
||||
// in the path (RFC 3986), which means greedy capture can swallow trailing
|
||||
// closing brackets that were really part of the surrounding sentence.
|
||||
// Peel off any trailing closer that has no matching opener inside the URL,
|
||||
// plus common trailing sentence punctuation.
|
||||
const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']);
|
||||
const trimUrlTrailingPunctuation = (url: string): string => {
|
||||
let result = url;
|
||||
while (result.length > 0) {
|
||||
const last = result[result.length - 1];
|
||||
if (last === ')' || last === ']' || last === '}' || last === '>') {
|
||||
const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<';
|
||||
// Count matched pairs in the rest of the URL; if there's no unmatched
|
||||
// opener, the closer is from surrounding text — strip it.
|
||||
const head = result.slice(0, -1);
|
||||
const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length;
|
||||
const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length;
|
||||
if (opens > closes) break;
|
||||
result = head;
|
||||
continue;
|
||||
}
|
||||
if (TRAILING_PUNCT.has(last)) {
|
||||
result = result.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const extractPreviewUrl = (chunk: string): string | null => {
|
||||
if (!chunk) return null;
|
||||
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const match = cleaned.match(URL_PATTERN);
|
||||
if (!match?.[1]) return null;
|
||||
let url = trimUrlTrailingPunctuation(match[1]);
|
||||
// Normalize common loopback hostnames to a stable value so the iframe can load.
|
||||
url = url.replace('0.0.0.0', '127.0.0.1');
|
||||
url = url.replace('[::1]', '127.0.0.1');
|
||||
url = url.replace('[::]', '127.0.0.1');
|
||||
return url;
|
||||
};
|
||||
|
||||
const extractPythonHttpServerUrl = (chunk: string): string | null => {
|
||||
if (!chunk) return null;
|
||||
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const match = cleaned.match(/Serving HTTP on .*? port (\d{2,5})/i);
|
||||
if (!match?.[1]) return null;
|
||||
const port = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
|
||||
return `http://127.0.0.1:${port}/`;
|
||||
};
|
||||
|
||||
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
|
||||
tabs: [firstTab],
|
||||
activeTabId: firstTab.id,
|
||||
@@ -110,6 +195,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessions: new Map(),
|
||||
projectActionRuns: {},
|
||||
nextChunkId: 1,
|
||||
nextTabId: 1,
|
||||
hasHydrated: typeof window === 'undefined',
|
||||
@@ -235,6 +321,39 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
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;
|
||||
}
|
||||
|
||||
const normalizedIconKey = iconKey?.trim() || null;
|
||||
if (existing.tabs[idx]?.iconKey === normalizedIconKey) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
iconKey: normalizedIconKey,
|
||||
};
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: nextTabs,
|
||||
});
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: async (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
@@ -262,12 +381,20 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
|
||||
const nextRuns = Object.fromEntries(
|
||||
Object.entries(state.projectActionRuns).filter(([, run]) => !(run.directory === key && run.tabId === tabId))
|
||||
);
|
||||
const runsChanged = Object.keys(nextRuns).length !== Object.keys(state.projectActionRuns).length;
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newTabId = `tab-${state.nextTabId}`;
|
||||
const newTab = createEmptyTab(newTabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(newTab));
|
||||
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
|
||||
return {
|
||||
sessions: newSessions,
|
||||
nextTabId: state.nextTabId + 1,
|
||||
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let nextActive = existing.activeTabId;
|
||||
@@ -282,7 +409,10 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
activeTabId: nextActive,
|
||||
});
|
||||
|
||||
return { sessions: newSessions };
|
||||
return {
|
||||
sessions: newSessions,
|
||||
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -397,11 +527,17 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
bufferLength -= removed.data.length;
|
||||
}
|
||||
|
||||
const maybePreviewUrl = tab.previewUrlLocked ? null : extractPreviewUrl(chunk) ?? extractPythonHttpServerUrl(chunk);
|
||||
const shouldUpdatePreview = Boolean(maybePreviewUrl && maybePreviewUrl !== tab.previewUrl);
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
...(shouldUpdatePreview
|
||||
? { previewUrl: maybePreviewUrl, previewAutoOpened: false }
|
||||
: null),
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
|
||||
@@ -409,6 +545,106 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options = {}) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
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;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const nextPreviewAutoOpened = options.autoOpened ?? tab.previewAutoOpened;
|
||||
const nextPreviewUrlLocked = options.locked ?? tab.previewUrlLocked;
|
||||
if (tab.previewUrl === url && tab.previewAutoOpened === nextPreviewAutoOpened && tab.previewUrlLocked === nextPreviewUrlLocked) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
previewUrl: url,
|
||||
previewAutoOpened: nextPreviewAutoOpened,
|
||||
previewUrlLocked: nextPreviewUrlLocked,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
markPreviewAutoOpened: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
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;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
if (!tab.previewUrl || tab.previewAutoOpened) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...tab, previewAutoOpened: true };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => {
|
||||
set((state) => {
|
||||
const existing = state.projectActionRuns[run.key];
|
||||
if (existing
|
||||
&& existing.directory === run.directory
|
||||
&& existing.actionId === run.actionId
|
||||
&& existing.tabId === run.tabId
|
||||
&& existing.sessionId === run.sessionId
|
||||
&& existing.status === run.status) {
|
||||
return state;
|
||||
}
|
||||
return { projectActionRuns: { ...state.projectActionRuns, [run.key]: run } };
|
||||
});
|
||||
},
|
||||
|
||||
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => {
|
||||
set((state) => {
|
||||
const existing = state.projectActionRuns[runKey];
|
||||
if (!existing || existing.status === status) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
projectActionRuns: {
|
||||
...state.projectActionRuns,
|
||||
[runKey]: { ...existing, status },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeProjectActionRun: (runKey: string) => {
|
||||
set((state) => {
|
||||
if (!state.projectActionRuns[runKey]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.projectActionRuns };
|
||||
delete next[runKey];
|
||||
return { projectActionRuns: next };
|
||||
});
|
||||
},
|
||||
|
||||
clearBuffer: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
@@ -439,12 +675,15 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
newSessions.delete(key);
|
||||
return { sessions: newSessions };
|
||||
const nextRuns = Object.fromEntries(
|
||||
Object.entries(state.projectActionRuns).filter(([, run]) => run.directory !== key)
|
||||
);
|
||||
return { sessions: newSessions, projectActionRuns: nextRuns };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ sessions: new Map(), nextChunkId: 1, nextTabId: 1 });
|
||||
set({ sessions: new Map(), projectActionRuns: {}, nextChunkId: 1, nextTabId: 1 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -458,6 +697,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
tabs: dirState.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
iconKey: tab.iconKey,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
lifecycle: tab.lifecycle,
|
||||
createdAt: tab.createdAt,
|
||||
@@ -519,12 +759,16 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null,
|
||||
terminalSessionId,
|
||||
lifecycle,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
previewUrlLocked: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
||||
export type RightSidebarTab = 'git' | 'files' | 'context';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
export type ChatRenderMode = 'sorted' | 'live';
|
||||
@@ -161,6 +161,10 @@ const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath
|
||||
return targetPath || mode;
|
||||
}
|
||||
|
||||
if (mode === 'preview') {
|
||||
return targetPath || mode;
|
||||
}
|
||||
|
||||
return mode;
|
||||
};
|
||||
|
||||
@@ -237,7 +241,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
touchedAt?: unknown;
|
||||
};
|
||||
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat') {
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -588,6 +592,7 @@ interface UIStore {
|
||||
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
||||
openContextOverview: (directory: string) => void;
|
||||
openContextPlan: (directory: string) => void;
|
||||
openContextPreview: (directory: string, url: string) => void;
|
||||
setActiveContextPanelTab: (directory: string, tabID: string) => void;
|
||||
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
|
||||
closeContextPanelTab: (directory: string, tabID: string) => void;
|
||||
@@ -991,6 +996,31 @@ export const useUIStore = create<UIStore>()(
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'plan' });
|
||||
},
|
||||
|
||||
openContextPreview: (directory, url) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedUrl = (url || '').trim();
|
||||
if (!normalizedDirectory || !normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let label: string | null = null;
|
||||
try {
|
||||
const parsed = new URL(normalizedUrl);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
label = parsed.host || parsed.hostname || 'Preview';
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URL
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, {
|
||||
mode: 'preview',
|
||||
targetPath: normalizedUrl,
|
||||
dedupeKey: normalizedUrl,
|
||||
label,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveContextPanelTab: (directory, tabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedTabID = (tabID || '').trim();
|
||||
|
||||
@@ -268,6 +268,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
let activeTransport: "ws" | "sse" = transport === "ws" ? "ws" : "sse"
|
||||
let attemptAbortReason: AttemptAbortReason = null
|
||||
let consecutiveFailures = 0
|
||||
|
||||
const notifyDisconnected = (reason: string) => {
|
||||
if (disconnected) {
|
||||
@@ -279,6 +280,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
|
||||
const markConnected = () => {
|
||||
disconnected = false
|
||||
consecutiveFailures = 0
|
||||
// Fire onReconnect on every successful connect — including the very
|
||||
// first one. Consumer state (isConnected) starts at false and needs
|
||||
// to be flipped positively; without this the send button throws
|
||||
@@ -382,9 +384,10 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let readyAt = 0
|
||||
const socket = new WebSocket(buildGlobalEventWsUrl(lastEventId))
|
||||
const setFallbackCode = (error: Error) => {
|
||||
if (!opened && transport === "auto") {
|
||||
const setFallbackCode = (error: Error, force = false) => {
|
||||
if ((force || !opened) && transport === "auto") {
|
||||
wsFallbackUntil = Date.now() + WS_FALLBACK_WINDOW_MS
|
||||
;(error as Error & { code?: string }).code = "WS_FALLBACK"
|
||||
}
|
||||
@@ -441,7 +444,8 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
signal.addEventListener("abort", handleAbort, { once: true })
|
||||
|
||||
socket.onopen = () => {
|
||||
streamErrorLogged = false
|
||||
// Don't clear streamErrorLogged here. If the socket immediately closes
|
||||
// before sending the ready frame, clearing would cause log spam.
|
||||
}
|
||||
|
||||
socket.onmessage = (messageEvent) => {
|
||||
@@ -462,10 +466,12 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
|
||||
if (frame.type === "ready") {
|
||||
opened = true
|
||||
readyAt = Date.now()
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = undefined
|
||||
}
|
||||
streamErrorLogged = false
|
||||
markConnected()
|
||||
return
|
||||
}
|
||||
@@ -517,7 +523,12 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
;(error as Error & { reason?: string }).reason = opened
|
||||
? `ws_closed:code=${event?.code ?? "?"}`
|
||||
: "ws_closed_before_ready"
|
||||
setFallbackCode(error)
|
||||
|
||||
// If the WS stream connects (ready) but then drops quickly, prefer SSE for a while.
|
||||
// This avoids tight reconnect loops with repeated console spam.
|
||||
const livedMs = readyAt > 0 ? Date.now() - readyAt : 0
|
||||
const unstableAfterReady = opened && livedMs > 0 && livedMs < 2_000
|
||||
setFallbackCode(error, unstableAfterReady)
|
||||
settleReject(error)
|
||||
}
|
||||
})
|
||||
@@ -567,6 +578,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
// a full directory resync.
|
||||
onTransportSwitch?.()
|
||||
} else if (!isAbortError(error)) {
|
||||
consecutiveFailures += 1
|
||||
if (!streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[event-pipeline] stream failed", error)
|
||||
@@ -587,6 +599,10 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
? `${currentTransport}_error:${message.slice(0, 80)}`
|
||||
: `${currentTransport}_error:unknown`
|
||||
notifyDisconnected(reason)
|
||||
|
||||
// Backoff so a hard-down server doesn't spin the browser event loop.
|
||||
// Cap at 5s; reset occurs in markConnected().
|
||||
retryDelayMs = Math.min(5_000, Math.max(retryDelayMs, 250) * (consecutiveFailures <= 1 ? 1 : 2))
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
|
||||
@@ -74,6 +74,8 @@ import { createPushRuntime } from './lib/notifications/push-runtime.js';
|
||||
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
|
||||
import webPush from 'web-push';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -1155,6 +1157,20 @@ async function main(options = {}) {
|
||||
writeSseEvent,
|
||||
});
|
||||
|
||||
const previewProxyRuntime = createPreviewProxyRuntime({
|
||||
crypto,
|
||||
URL,
|
||||
createProxyMiddleware,
|
||||
responseInterceptor,
|
||||
});
|
||||
previewProxyRuntime.attach(app, {
|
||||
server,
|
||||
express,
|
||||
uiAuthController,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
});
|
||||
|
||||
const startupPipelineResult = await startupPipelineRuntime.run({
|
||||
app,
|
||||
server,
|
||||
|
||||
@@ -354,6 +354,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
app.get('/api/fs/read', async (req, res) => {
|
||||
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
||||
const optional = req.query.optional === 'true';
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
@@ -391,6 +392,9 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
if (optional) {
|
||||
return res.type('text/plain').send('');
|
||||
}
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
} = options;
|
||||
|
||||
registerServerStatusRoutes(app, {
|
||||
express,
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
const {
|
||||
express,
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
@@ -8,6 +9,135 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
getHealthSnapshot,
|
||||
} = dependencies;
|
||||
|
||||
const allocateLoopbackPort = async () => {
|
||||
const net = await import('node:net');
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
try {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === 'object' ? address.port : 0;
|
||||
server.close(() => {
|
||||
resolve(port);
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isDevShutdownAllowed = () => {
|
||||
// Dev-only escape hatch: allow terminating the whole dev process group.
|
||||
// This should never be enabled in production runtimes.
|
||||
return process.env.OPENCHAMBER_DEV_SHUTDOWN === 'true';
|
||||
};
|
||||
|
||||
const isSameOriginRequest = (req) => {
|
||||
const rawOrigin = typeof req.get === 'function' ? req.get('origin') : '';
|
||||
const rawHost = typeof req.get === 'function' ? req.get('host') : '';
|
||||
if (!rawOrigin || !rawHost) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const origin = new URL(rawOrigin);
|
||||
return origin.host === rawHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveProcessGroupId = async (pid) => {
|
||||
if (!pid || typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { execFile } = await import('node:child_process');
|
||||
const { promisify } = await import('node:util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const result = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)]);
|
||||
const raw = String(result.stdout || '').trim();
|
||||
const pgid = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(pgid) && pgid > 0 ? pgid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseLoopbackPort = (rawUrl) => {
|
||||
if (typeof rawUrl !== 'string') {
|
||||
return null;
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
const host = url.hostname;
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '0.0.0.0') {
|
||||
return null;
|
||||
}
|
||||
const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
const killListenPort = async (port) => {
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { execFile } = await import('node:child_process');
|
||||
const { promisify } = await import('node:util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const result = await execFileAsync('lsof', ['-nP', '-t', `-iTCP:${Math.trunc(port)}`, '-sTCP:LISTEN'], {
|
||||
timeout: 2500,
|
||||
});
|
||||
const pids = String(result.stdout || '')
|
||||
.split(/\s+/)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid);
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (pids.length > 0) {
|
||||
setTimeout(() => {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}, 1200).unref?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore (no lsof, no permission, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
@@ -23,6 +153,70 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/system/dev-shutdown', express.json({ limit: '64kb' }), async (req, res) => {
|
||||
if (!isDevShutdownAllowed()) {
|
||||
return res.status(403).json({ ok: false, error: 'Dev shutdown is disabled' });
|
||||
}
|
||||
if (!isSameOriginRequest(req)) {
|
||||
return res.status(403).json({ ok: false, error: 'Invalid origin' });
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
|
||||
// Terminate the entire dev process group so `bun run dev` leaves no orphans.
|
||||
// We still run graceful shutdown to clean up OpenCode, terminals, websockets.
|
||||
try {
|
||||
const rawPreviewUrls = Array.isArray(req.body?.previewUrls) ? req.body.previewUrls : [];
|
||||
const previewPorts = Array.from(new Set(
|
||||
rawPreviewUrls
|
||||
.map((value) => parseLoopbackPort(value))
|
||||
.filter((port) => typeof port === 'number')
|
||||
));
|
||||
// Attempt to stop preview servers that may have daemonized away from the PTY.
|
||||
// This is dev-only and limited to loopback ports supplied by the UI.
|
||||
await Promise.all(previewPorts.map((port) => killListenPort(port)));
|
||||
|
||||
const pgid = await resolveProcessGroupId(process.pid);
|
||||
const ppid = typeof process.ppid === 'number' ? process.ppid : null;
|
||||
const parentPgid = ppid ? await resolveProcessGroupId(ppid) : null;
|
||||
|
||||
// Kick off shutdown cleanup first.
|
||||
void gracefulShutdown({ exitProcess: false });
|
||||
|
||||
const pgidsToKill = Array.from(new Set([pgid, parentPgid].filter(Boolean)));
|
||||
for (const id of pgidsToKill) {
|
||||
try {
|
||||
process.kill(-id, 'SIGTERM');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (const id of pgidsToKill) {
|
||||
try {
|
||||
process.kill(-id, 'SIGKILL');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}, 1500).unref?.();
|
||||
|
||||
// Ensure the server process itself exits even if the group kill fails.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.exit(0);
|
||||
} catch {
|
||||
}
|
||||
}, 2500).unref?.();
|
||||
} catch (error) {
|
||||
console.error('Dev shutdown request failed:', error?.message || error);
|
||||
// As a last resort, exit.
|
||||
try {
|
||||
process.exit(0);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/info', (_req, res) => {
|
||||
res.json({
|
||||
openchamberVersion,
|
||||
@@ -31,6 +225,20 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
startedAt: serverStartedAt,
|
||||
});
|
||||
});
|
||||
|
||||
// Allocates a best-effort free TCP port hint on 127.0.0.1.
|
||||
// Another process can still claim it before the preview server binds.
|
||||
app.get('/api/system/free-port', async (_req, res) => {
|
||||
try {
|
||||
const port = await allocateLoopbackPort();
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return res.status(500).json({ error: 'Failed to allocate port' });
|
||||
}
|
||||
return res.json({ port });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error && error.message) || 'Failed to allocate port' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
const DEFAULT_TARGET_TTL_MS = 30 * 60 * 1000;
|
||||
const TOKEN_COOKIE_NAME = 'oc_preview_token';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'[::1]',
|
||||
'0.0.0.0',
|
||||
]);
|
||||
|
||||
const PREVIEW_BRIDGE_SCRIPT_ID = 'openchamber-preview-bridge';
|
||||
|
||||
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
||||
if (window.__openchamberPreviewBridgeInstalled) return;
|
||||
window.__openchamberPreviewBridgeInstalled = true;
|
||||
|
||||
const SOURCE = 'openchamber-preview-bridge';
|
||||
const VERSION = 1;
|
||||
const MAX_TEXT = 500;
|
||||
const MAX_ARG = 1000;
|
||||
let inspectMode = false;
|
||||
let lastHoverKey = '';
|
||||
let pendingHover = null;
|
||||
|
||||
const post = (payload) => {
|
||||
try {
|
||||
if (window.parent && typeof window.parent.postMessage === 'function') {
|
||||
const message = Object.assign({ source: SOURCE, version: VERSION }, payload || {});
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const clip = (value, max = MAX_TEXT) => {
|
||||
const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
||||
return text.length > max ? text.slice(0, max) + '...' : text;
|
||||
};
|
||||
|
||||
const stringifyArg = (value) => {
|
||||
if (typeof value === 'string') return clip(value, MAX_ARG);
|
||||
if (value instanceof Error) return clip(value.stack || value.message || String(value), MAX_ARG);
|
||||
try {
|
||||
return clip(JSON.stringify(value), MAX_ARG);
|
||||
} catch {
|
||||
return clip(String(value), MAX_ARG);
|
||||
}
|
||||
};
|
||||
|
||||
const readElementUrl = (element) => {
|
||||
return element.currentSrc || element.src || element.href || element.action || '';
|
||||
};
|
||||
|
||||
const upstreamPathForUrl = (value) => {
|
||||
try {
|
||||
const parsed = new URL(value, window.location.href);
|
||||
const match = parsed.pathname.match(/^\/api\/preview\/proxy\/[a-f0-9]{16,64}(\/.*)?$/i);
|
||||
return match ? (match[1] || '/') : parsed.pathname;
|
||||
} catch {
|
||||
return String(value || '');
|
||||
}
|
||||
};
|
||||
|
||||
const upstreamPathAndSearchForUrl = (value) => {
|
||||
try {
|
||||
const parsed = new URL(value, window.location.href);
|
||||
const match = parsed.pathname.match(/^\/api\/preview\/proxy\/[a-f0-9]{16,64}(\/.*)?$/i);
|
||||
const path = match ? (match[1] || '/') : parsed.pathname;
|
||||
return path + parsed.search;
|
||||
} catch {
|
||||
return String(value || '');
|
||||
}
|
||||
};
|
||||
|
||||
const isInternalDevToolResource = (element, value) => {
|
||||
const tag = element && element.tagName && typeof element.tagName.toLowerCase === 'function' ? element.tagName.toLowerCase() : '';
|
||||
if (tag !== 'script' && tag !== 'link') return false;
|
||||
const path = upstreamPathForUrl(value);
|
||||
const pathAndSearch = upstreamPathAndSearchForUrl(value);
|
||||
return path === '/@vite/client'
|
||||
|| path === '/@react-refresh'
|
||||
|| path.indexOf('/@id/astro:') === 0
|
||||
|| path.startsWith('/@id/__x00__vite/')
|
||||
|| path.includes('/node_modules/.vite/')
|
||||
|| path.includes('/vite/dist/client/')
|
||||
|| path.includes('/astro/dist/runtime/client/dev-toolbar/')
|
||||
|| (pathAndSearch.indexOf('/node_modules/') >= 0 && pathAndSearch.indexOf('.astro?') >= 0 && pathAndSearch.indexOf('type=script') >= 0);
|
||||
};
|
||||
|
||||
const toLoopbackHttpUrl = (value) => {
|
||||
try {
|
||||
const parsed = new URL(value, window.location.href);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
const host = parsed.hostname;
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '[::1]') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldParentHandleNavigation = (url) => {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const current = new URL(window.location.href);
|
||||
if (parsed.origin !== current.origin) return true;
|
||||
return !parsed.pathname.startsWith('/api/preview/proxy/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isInternalDevToolRuntimeError = (filename) => {
|
||||
const path = upstreamPathForUrl(filename || '');
|
||||
const pathAndSearch = upstreamPathAndSearchForUrl(filename || '');
|
||||
const lowerPathAndSearch = pathAndSearch.toLowerCase();
|
||||
const isStyleRuntimeNoise = lowerPathAndSearch.endsWith('.css')
|
||||
|| lowerPathAndSearch.indexOf('.css?') >= 0
|
||||
|| lowerPathAndSearch.indexOf('type=style') >= 0
|
||||
|| lowerPathAndSearch.indexOf('lang.css') >= 0;
|
||||
return path === '/@vite/client'
|
||||
|| path === '/@react-refresh'
|
||||
|| path.indexOf('/astro/dist/runtime/client/dev-toolbar/') >= 0
|
||||
|| path.indexOf('/node_modules/.vite/') >= 0
|
||||
|| isStyleRuntimeNoise;
|
||||
};
|
||||
|
||||
const installViteHmrProxyPatch = () => {
|
||||
if (window.__openchamberViteHmrProxyPatched || typeof window.WebSocket !== 'function') return;
|
||||
window.__openchamberViteHmrProxyPatched = true;
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
|
||||
if (!proxyMatch) return;
|
||||
const proxyBase = proxyMatch[1] + '/';
|
||||
let reloadTimer = 0;
|
||||
|
||||
const schedulePreviewReload = () => {
|
||||
if (reloadTimer) return;
|
||||
reloadTimer = window.setTimeout(() => {
|
||||
reloadTimer = 0;
|
||||
try {
|
||||
window.location.reload();
|
||||
} catch {}
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const rewriteUrl = (url, protocols) => {
|
||||
const protocolList = Array.isArray(protocols) ? protocols : [protocols];
|
||||
const isViteSocket = protocolList.indexOf('vite-hmr') >= 0 || protocolList.indexOf('vite-ping') >= 0;
|
||||
if (!isViteSocket) return url;
|
||||
try {
|
||||
const parsed = new URL(String(url), window.location.href);
|
||||
if (parsed.host !== window.location.host) return url;
|
||||
if (parsed.pathname.indexOf(proxyBase) === 0) return url;
|
||||
parsed.pathname = proxyBase;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
function OpenChamberPreviewWebSocket(url, protocols) {
|
||||
const protocolList = Array.isArray(protocols) ? protocols : [protocols];
|
||||
const isViteSocket = protocolList.indexOf('vite-hmr') >= 0;
|
||||
const nextUrl = rewriteUrl(url, protocols);
|
||||
const socket = arguments.length === 1
|
||||
? new NativeWebSocket(nextUrl)
|
||||
: new NativeWebSocket(nextUrl, protocols);
|
||||
|
||||
if (isViteSocket) {
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data || ''));
|
||||
if (payload && (payload.type === 'update' || payload.type === 'full-reload')) {
|
||||
schedulePreviewReload();
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
OpenChamberPreviewWebSocket.prototype = NativeWebSocket.prototype;
|
||||
Object.setPrototypeOf(OpenChamberPreviewWebSocket, NativeWebSocket);
|
||||
Object.defineProperty(OpenChamberPreviewWebSocket, 'name', { value: 'WebSocket' });
|
||||
window.WebSocket = OpenChamberPreviewWebSocket;
|
||||
};
|
||||
|
||||
const selectorPart = (element) => {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (element.id && /^[A-Za-z][\w:.-]*$/.test(element.id)) return tag + '#' + CSS.escape(element.id);
|
||||
const testId = element.getAttribute('data-testid') || element.getAttribute('data-test') || element.getAttribute('data-cy');
|
||||
if (testId) return tag + '[data-testid="' + CSS.escape(testId) + '"]';
|
||||
const classes = Array.from(element.classList || []).slice(0, 3).map((entry) => '.' + CSS.escape(entry)).join('');
|
||||
return tag + classes;
|
||||
};
|
||||
|
||||
const buildSelector = (element) => {
|
||||
const parts = [];
|
||||
let current = element;
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
|
||||
let part = selectorPart(current);
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
|
||||
if (siblings.length > 1 && !part.includes('#') && !part.includes('[data-testid=')) {
|
||||
part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||||
}
|
||||
}
|
||||
parts.unshift(part);
|
||||
if (part.includes('#')) break;
|
||||
current = parent;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
};
|
||||
|
||||
const metadataForElement = (element) => {
|
||||
if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const attributes = {};
|
||||
for (const name of ['id', 'class', 'role', 'aria-label', 'href', 'src', 'data-testid', 'data-test', 'data-cy']) {
|
||||
const value = typeof element.getAttribute === 'function' ? element.getAttribute(name) : null;
|
||||
if (value) attributes[name] = clip(value, 300);
|
||||
}
|
||||
const ancestry = [];
|
||||
let current = element;
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && ancestry.length < 6) {
|
||||
ancestry.unshift({
|
||||
tag: current.tagName.toLowerCase(),
|
||||
id: current.id || undefined,
|
||||
className: clip(current.className || '', 200) || undefined,
|
||||
selectorPart: selectorPart(current),
|
||||
});
|
||||
current = current.parentElement;
|
||||
}
|
||||
return {
|
||||
frame: 'top',
|
||||
tag: element.tagName.toLowerCase(),
|
||||
text: clip(element.innerText || element.textContent || ''),
|
||||
selector: buildSelector(element),
|
||||
path: ancestry.map((entry) => entry.tag).join(' > '),
|
||||
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
|
||||
attributes,
|
||||
computedStyle: {
|
||||
display: style.display,
|
||||
position: style.position,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
lineHeight: style.lineHeight,
|
||||
zIndex: style.zIndex,
|
||||
},
|
||||
ancestry,
|
||||
};
|
||||
};
|
||||
|
||||
const hoverKeyForTarget = (target) => {
|
||||
if (!target) return '';
|
||||
const bounds = target.bounds || {};
|
||||
return [target.selector, Math.round(bounds.x), Math.round(bounds.y), Math.round(bounds.width), Math.round(bounds.height)].join('|');
|
||||
};
|
||||
|
||||
const sendHover = (event) => {
|
||||
if (!inspectMode) return;
|
||||
pendingHover = event;
|
||||
if (window.__openchamberPreviewHoverFrame) return;
|
||||
window.__openchamberPreviewHoverFrame = window.requestAnimationFrame(() => {
|
||||
window.__openchamberPreviewHoverFrame = 0;
|
||||
const currentEvent = pendingHover;
|
||||
pendingHover = null;
|
||||
if (!currentEvent || !inspectMode) return;
|
||||
const element = document.elementFromPoint(currentEvent.clientX, currentEvent.clientY);
|
||||
const target = metadataForElement(element);
|
||||
const key = hoverKeyForTarget(target);
|
||||
if (key === lastHoverKey) return;
|
||||
lastHoverKey = key;
|
||||
post({ type: 'hover', target, pointer: { x: currentEvent.clientX, y: currentEvent.clientY }, ts: Date.now() });
|
||||
});
|
||||
};
|
||||
|
||||
const setInspectMode = (enabled) => {
|
||||
inspectMode = Boolean(enabled);
|
||||
lastHoverKey = '';
|
||||
document.documentElement.style.cursor = inspectMode ? 'crosshair' : '';
|
||||
if (!inspectMode) {
|
||||
post({ type: 'hover', target: null, pointer: { x: 0, y: 0 }, ts: Date.now() });
|
||||
}
|
||||
};
|
||||
|
||||
for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
|
||||
const original = console[level];
|
||||
console[level] = function() {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
if (level === 'debug' && typeof args[0] === 'string' && args[0].indexOf('[vite]') === 0) {
|
||||
return original.apply(console, args);
|
||||
}
|
||||
post({ type: 'console', level, args: args.map(stringifyArg), ts: Date.now() });
|
||||
return original.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
installViteHmrProxyPatch();
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
const target = event.target;
|
||||
if (target && target !== window && target.nodeType === Node.ELEMENT_NODE) {
|
||||
const url = readElementUrl(target);
|
||||
if (isInternalDevToolResource(target, url)) {
|
||||
return;
|
||||
}
|
||||
post({
|
||||
type: 'resource-error',
|
||||
tag: target.tagName.toLowerCase(),
|
||||
url: clip(url, 1000),
|
||||
outerHTML: clip(target.outerHTML || '', 1000),
|
||||
ts: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isInternalDevToolRuntimeError(event.filename)) {
|
||||
return;
|
||||
}
|
||||
post({
|
||||
type: 'runtime-error',
|
||||
message: clip(event.message || 'Unknown error', 1000),
|
||||
stack: clip(event.error && event.error.stack ? event.error.stack : '', 2000) || undefined,
|
||||
filename: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}, true);
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
post({
|
||||
type: 'runtime-error',
|
||||
message: clip(event.reason && event.reason.message ? event.reason.message : event.reason || 'Unhandled promise rejection', 1000),
|
||||
stack: clip(event.reason && event.reason.stack ? event.reason.stack : '', 2000) || undefined,
|
||||
ts: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window.parent) return;
|
||||
const data = event.data;
|
||||
if (!data || data.source !== 'openchamber-preview-parent' || data.version !== VERSION) return;
|
||||
if (data.type === 'set-inspect-mode') {
|
||||
setInspectMode(data.enabled === true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', sendHover, true);
|
||||
window.addEventListener('mouseleave', () => {
|
||||
if (!inspectMode) return;
|
||||
lastHoverKey = '';
|
||||
post({ type: 'hover', target: null, pointer: { x: 0, y: 0 }, ts: Date.now() });
|
||||
}, true);
|
||||
window.addEventListener('click', (event) => {
|
||||
const anchor = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null;
|
||||
if (anchor && !inspectMode) {
|
||||
const nextUrl = toLoopbackHttpUrl(anchor.href);
|
||||
if (shouldParentHandleNavigation(nextUrl)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
post({ type: 'navigate-preview', url: nextUrl, ts: Date.now() });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inspectMode) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const target = metadataForElement(element);
|
||||
if (target) {
|
||||
post({ type: 'select', target, pointer: { x: event.clientX, y: event.clientY }, ts: Date.now() });
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
post({ type: 'ready', url: window.location.href, title: document.title || '' });
|
||||
});
|
||||
post({ type: 'ready', url: window.location.href, title: document.title || '' });
|
||||
})();`;
|
||||
|
||||
const parseCookieHeader = (cookieHeader) => {
|
||||
const result = new Map();
|
||||
if (typeof cookieHeader !== 'string' || cookieHeader.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const parts = cookieHeader.split(';');
|
||||
for (const part of parts) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, idx).trim();
|
||||
const value = part.slice(idx + 1).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
result.set(key, value);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const buildCookie = ({
|
||||
name,
|
||||
value,
|
||||
path,
|
||||
maxAgeSeconds,
|
||||
secure,
|
||||
}) => {
|
||||
const chunks = [`${name}=${value}`];
|
||||
if (path) chunks.push(`Path=${path}`);
|
||||
if (typeof maxAgeSeconds === 'number' && Number.isFinite(maxAgeSeconds)) {
|
||||
chunks.push(`Max-Age=${Math.max(0, Math.trunc(maxAgeSeconds))}`);
|
||||
}
|
||||
chunks.push('HttpOnly');
|
||||
chunks.push('SameSite=Lax');
|
||||
if (secure) chunks.push('Secure');
|
||||
return chunks.join('; ');
|
||||
};
|
||||
|
||||
const normalizeLoopbackUrl = (rawUrl) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { ok: false, error: 'Invalid URL' };
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, error: 'Only http(s) URLs are supported' };
|
||||
}
|
||||
|
||||
const hostname = url.hostname;
|
||||
if (!LOOPBACK_HOSTS.has(hostname)) {
|
||||
return { ok: false, error: 'Only loopback hosts are supported' };
|
||||
}
|
||||
|
||||
const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
return { ok: false, error: 'Invalid port' };
|
||||
}
|
||||
|
||||
// Normalize common loopback hostnames to IPv4 to avoid environments where
|
||||
// `localhost` resolves to ::1 but the dev server only binds IPv4.
|
||||
if (hostname === '0.0.0.0' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') {
|
||||
url.hostname = '127.0.0.1';
|
||||
}
|
||||
|
||||
// Only keep origin here; the proxy path is preserved on the OpenChamber side.
|
||||
return { ok: true, origin: url.origin };
|
||||
};
|
||||
|
||||
export const createPreviewProxyRuntime = ({
|
||||
crypto,
|
||||
URL,
|
||||
createProxyMiddleware,
|
||||
responseInterceptor,
|
||||
}) => {
|
||||
const targets = new Map();
|
||||
let sweepTimer = null;
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
const sweepExpired = () => {
|
||||
const t = now();
|
||||
for (const [id, entry] of targets.entries()) {
|
||||
if (entry.expiresAt <= t) {
|
||||
targets.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSweeper = () => {
|
||||
if (sweepTimer) {
|
||||
return;
|
||||
}
|
||||
sweepTimer = setInterval(sweepExpired, 30_000);
|
||||
// Don't keep the process alive.
|
||||
sweepTimer.unref?.();
|
||||
};
|
||||
|
||||
const createTarget = (origin, ttlMs) => {
|
||||
const id = crypto.randomBytes(16).toString('hex');
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
const createdAt = now();
|
||||
const expiresAt = createdAt + (Number.isFinite(ttlMs) ? Math.max(15_000, Math.trunc(ttlMs)) : DEFAULT_TARGET_TTL_MS);
|
||||
targets.set(id, {
|
||||
id,
|
||||
origin,
|
||||
token,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
});
|
||||
return { id, token, expiresAt };
|
||||
};
|
||||
|
||||
const resolveTargetFromRequest = (req) => {
|
||||
const rawUrl = req?.originalUrl || req?.url || '';
|
||||
const parsed = new URL(rawUrl, 'http://localhost');
|
||||
const pathname = parsed.pathname || '';
|
||||
|
||||
const match = pathname.match(/^\/api\/preview\/proxy\/([a-f0-9]{16,64})(?:\/|$)/i);
|
||||
const id = match?.[1] || '';
|
||||
if (!id) {
|
||||
return { ok: false, status: 404, error: 'Preview target not found' };
|
||||
}
|
||||
|
||||
const entry = targets.get(id);
|
||||
if (!entry || entry.expiresAt <= now()) {
|
||||
targets.delete(id);
|
||||
return { ok: false, status: 404, error: 'Preview target expired' };
|
||||
}
|
||||
|
||||
const cookies = parseCookieHeader(req.headers?.cookie);
|
||||
const token = cookies.get(TOKEN_COOKIE_NAME) || '';
|
||||
if (!token || token !== entry.token) {
|
||||
return { ok: false, status: 403, error: 'Preview token missing' };
|
||||
}
|
||||
|
||||
return { ok: true, id, entry, parsed };
|
||||
};
|
||||
|
||||
const stripProxyPrefix = (pathname, id) => {
|
||||
const prefix = `/api/preview/proxy/${id}`;
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return pathname;
|
||||
}
|
||||
const rest = pathname.slice(prefix.length);
|
||||
return rest.length === 0 ? '/' : rest;
|
||||
};
|
||||
|
||||
const removeRawQueryParam = (search, paramName) => {
|
||||
if (typeof search !== 'string' || search.length <= 1) {
|
||||
return '';
|
||||
}
|
||||
const query = search.startsWith('?') ? search.slice(1) : search;
|
||||
const parts = query.split('&').filter((part) => {
|
||||
const name = part.split('=', 1)[0] || '';
|
||||
return decodeURIComponent(name.replace(/\+/g, ' ')) !== paramName;
|
||||
});
|
||||
return parts.length > 0 ? `?${parts.join('&')}` : '';
|
||||
};
|
||||
|
||||
// Strip the `frame-ancestors` directive from a CSP header value while
|
||||
// preserving every other directive. Returns null if no directives remain.
|
||||
const removeFrameAncestorsDirective = (cspValue) => {
|
||||
if (typeof cspValue !== 'string' || cspValue.length === 0) {
|
||||
return cspValue;
|
||||
}
|
||||
const directives = cspValue
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
const filtered = directives.filter((directive) => {
|
||||
const name = directive.split(/\s+/, 1)[0]?.toLowerCase() ?? '';
|
||||
return name !== 'frame-ancestors';
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filtered.join('; ');
|
||||
};
|
||||
|
||||
// Drop response headers that prevent the dev server from being framed.
|
||||
// The proxy itself is same-origin, so embedding is otherwise safe.
|
||||
const stripFrameBustingHeaders = (headers) => {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerKeys = Object.keys(headers);
|
||||
for (const key of headerKeys) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === 'x-frame-options') {
|
||||
delete headers[key];
|
||||
continue;
|
||||
}
|
||||
if (lowerKey === 'content-security-policy' || lowerKey === 'content-security-policy-report-only') {
|
||||
const original = headers[key];
|
||||
const values = Array.isArray(original) ? original : [original];
|
||||
const rewritten = values
|
||||
.map((value) => removeFrameAncestorsDirective(value))
|
||||
.filter((value) => typeof value === 'string' && value.length > 0);
|
||||
if (rewritten.length === 0) {
|
||||
delete headers[key];
|
||||
} else {
|
||||
headers[key] = Array.isArray(original) ? rewritten : rewritten[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const attach = (app, {
|
||||
server,
|
||||
express,
|
||||
uiAuthController,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
}) => {
|
||||
ensureSweeper();
|
||||
|
||||
const rewritePreviewBody = (bodyText, proxyBasePath) => {
|
||||
if (typeof bodyText !== 'string' || bodyText.length === 0) {
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
|
||||
const rewriteRootPath = (value) => {
|
||||
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return value;
|
||||
}
|
||||
if (value.startsWith('/api/preview/proxy/')) {
|
||||
return value;
|
||||
}
|
||||
return `${prefix}${value}`;
|
||||
};
|
||||
|
||||
return bodyText
|
||||
.replace(/\b(src|href|action)=(["'])\/(?!\/)([^"']*)\2/gi, (_match, attr, quote, path) => {
|
||||
return `${attr}=${quote}${rewriteRootPath(`/${path}`)}${quote}`;
|
||||
})
|
||||
.replace(/\bsrcset=(["'])([^"']*)\1/gi, (_match, quote, value) => {
|
||||
const rewritten = String(value).split(',').map((part) => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const segments = trimmed.split(/\s+/);
|
||||
const url = segments[0] || '';
|
||||
segments[0] = rewriteRootPath(url);
|
||||
return segments.join(' ');
|
||||
}).join(', ');
|
||||
return `srcset=${quote}${rewritten}${quote}`;
|
||||
})
|
||||
.replace(/url\((['"]?)\/(?!\/)([^)'"]*)\1\)/gi, (_match, quote, path) => {
|
||||
const q = quote || '';
|
||||
return `url(${q}${rewriteRootPath(`/${path}`)}${q})`;
|
||||
})
|
||||
.replace(/@import\s+(["'])\/(?!\/)([^"']*)\1/gi, (_match, quote, path) => {
|
||||
return `@import ${quote}${rewriteRootPath(`/${path}`)}${quote}`;
|
||||
});
|
||||
};
|
||||
|
||||
const injectPreviewBridge = (bodyText) => {
|
||||
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
const script = `<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
|
||||
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
|
||||
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
|
||||
}
|
||||
if (bodyText.includes('</body>')) {
|
||||
return bodyText.replace('</body>', `${script}</body>`);
|
||||
}
|
||||
return `${bodyText}${script}`;
|
||||
};
|
||||
|
||||
const rewriteViteClientHmr = (bodyText, proxyBasePath) => {
|
||||
if (typeof bodyText !== 'string' || !bodyText.includes('vite-hmr')) {
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
const base = proxyBasePath.endsWith('/') ? proxyBasePath : `${proxyBasePath}/`;
|
||||
const escapedBase = JSON.stringify(base).slice(1, -1);
|
||||
return bodyText
|
||||
.replace(/const base\$1 = [^;]+;/, () => `const base$1 = ${JSON.stringify(base)};`)
|
||||
.replace(/const base = [^;]+;/, () => `const base = ${JSON.stringify(base)};`)
|
||||
.replace(/const hmrPort = [^;]+;/, () => 'const hmrPort = importMetaUrl.port;')
|
||||
.replace(/const socketHost = [^;]+;/, () => `const socketHost = \`\${importMetaUrl.hostname}\${importMetaUrl.port ? ':' + importMetaUrl.port : ''}${escapedBase}\`;`)
|
||||
.replace(/const directSocketHost = [^;]+;/, () => 'const directSocketHost = socketHost;')
|
||||
.replace(
|
||||
/const socketHost = `\$\{[^;]+?;\nconst directSocketHost = [^;]+;/s,
|
||||
() => `const socketHost = \`\${importMetaUrl.hostname}\${importMetaUrl.port ? ':' + importMetaUrl.port : ''}${escapedBase}\`;\nconst directSocketHost = socketHost;`,
|
||||
);
|
||||
};
|
||||
|
||||
app.post('/api/preview/targets', express.json(), async (req, res) => {
|
||||
try {
|
||||
if (uiAuthController?.enabled) {
|
||||
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, res);
|
||||
if (!sessionToken) {
|
||||
return res.status(401).json({ error: 'UI authentication required' });
|
||||
}
|
||||
|
||||
const originAllowed = await isRequestOriginAllowed(req);
|
||||
if (!originAllowed) {
|
||||
return res.status(403).json({ error: 'Invalid origin' });
|
||||
}
|
||||
}
|
||||
|
||||
const rawUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
|
||||
if (!rawUrl) {
|
||||
return res.status(400).json({ error: 'url is required' });
|
||||
}
|
||||
|
||||
const ttlMs = typeof req.body?.ttlMs === 'number' ? req.body.ttlMs : DEFAULT_TARGET_TTL_MS;
|
||||
const normalized = normalizeLoopbackUrl(rawUrl);
|
||||
if (!normalized.ok) {
|
||||
return res.status(400).json({ error: normalized.error });
|
||||
}
|
||||
|
||||
const target = createTarget(normalized.origin, ttlMs);
|
||||
const cookiePath = `/api/preview/proxy/${target.id}`;
|
||||
const secure = Boolean(req.secure);
|
||||
res.setHeader('Set-Cookie', buildCookie({
|
||||
name: TOKEN_COOKIE_NAME,
|
||||
value: target.token,
|
||||
path: cookiePath,
|
||||
maxAgeSeconds: Math.round((target.expiresAt - now()) / 1000),
|
||||
secure,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
id: target.id,
|
||||
proxyBasePath: cookiePath,
|
||||
expiresAt: target.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[preview-proxy] Failed to create target:', error);
|
||||
return res.status(500).json({ error: 'Failed to create preview target' });
|
||||
}
|
||||
});
|
||||
|
||||
const proxy = createProxyMiddleware({
|
||||
target: 'http://127.0.0.1',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
selfHandleResponse: true,
|
||||
// Restrict the proxy (especially its auto-attached `upgrade` listener,
|
||||
// which is registered globally on the underlying HTTP server when
|
||||
// `ws: true`) to preview paths. Without this, every WebSocket upgrade
|
||||
// on the server (e.g. `/api/terminal/ws`) gets proxied to
|
||||
// `http://127.0.0.1` and tears the socket down with ECONNREFUSED.
|
||||
//
|
||||
// We use a function so the same filter handles both cases:
|
||||
// - HTTP requests through Express, where `req.url` has been stripped
|
||||
// of the `/api/preview/proxy` mount-point, so we check `originalUrl`.
|
||||
// - Raw upgrade events from the HTTP server, where `req.url` still
|
||||
// contains the full path.
|
||||
pathFilter: (pathname, req) => {
|
||||
const target = req?.originalUrl || pathname || req?.url || '';
|
||||
return target.startsWith('/api/preview/proxy/');
|
||||
},
|
||||
router: (req) => {
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return 'http://127.0.0.1';
|
||||
}
|
||||
return resolved.entry.origin;
|
||||
},
|
||||
pathRewrite: (pathValue, req) => {
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
|
||||
// Never forward our auth cookie token to the dev server.
|
||||
const strippedPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||
return `${strippedPath}${removeRawQueryParam(parsed.search, 'ocPreview')}`;
|
||||
},
|
||||
on: {
|
||||
proxyReq: (proxyReq) => {
|
||||
// Keep local dev servers from receiving OpenChamber credentials.
|
||||
proxyReq.removeHeader('cookie');
|
||||
proxyReq.removeHeader('authorization');
|
||||
proxyReq.removeHeader('x-openchamber-ui-session');
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
},
|
||||
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req) => {
|
||||
// Allow the dev server response to be framed inside OpenChamber even
|
||||
// if it normally sets X-Frame-Options or a CSP frame-ancestors rule.
|
||||
// The proxy is same-origin so embedding is otherwise safe.
|
||||
stripFrameBustingHeaders(proxyRes.headers);
|
||||
|
||||
const contentType = String(proxyRes.headers?.['content-type'] || '').toLowerCase();
|
||||
const isHtml = contentType.includes('text/html');
|
||||
const isCss = contentType.includes('text/css');
|
||||
const isJavaScript = contentType.includes('javascript') || contentType.includes('ecmascript');
|
||||
if (!isHtml && !isCss && !isJavaScript) {
|
||||
return responseBuffer;
|
||||
}
|
||||
|
||||
proxyRes.headers['cache-control'] = 'no-store, no-cache, must-revalidate, proxy-revalidate';
|
||||
proxyRes.headers.pragma = 'no-cache';
|
||||
proxyRes.headers.expires = '0';
|
||||
delete proxyRes.headers.etag;
|
||||
delete proxyRes.headers['last-modified'];
|
||||
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return responseBuffer;
|
||||
}
|
||||
|
||||
const proxyBasePath = `/api/preview/proxy/${resolved.id}`;
|
||||
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
|
||||
const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||
if (isJavaScript && upstreamPath === '/@vite/client') {
|
||||
return rewriteViteClientHmr(responseBuffer.toString('utf8'), proxyBasePath);
|
||||
}
|
||||
|
||||
const rewrittenBody = rewritePreviewBody(responseBuffer.toString('utf8'), proxyBasePath);
|
||||
return isHtml ? injectPreviewBridge(rewrittenBody) : rewrittenBody;
|
||||
}),
|
||||
error: (err, _req, res) => {
|
||||
const isDev = typeof process !== 'undefined'
|
||||
&& process
|
||||
&& process.env
|
||||
&& process.env.NODE_ENV !== 'production';
|
||||
|
||||
const message = err && typeof err === 'object' && typeof err.message === 'string'
|
||||
? err.message
|
||||
: 'Unknown proxy error';
|
||||
|
||||
console.error('[preview-proxy] proxy error:', message);
|
||||
|
||||
if (res && !res.headersSent && typeof res.status === 'function') {
|
||||
const payload = { error: 'Preview proxy error' };
|
||||
|
||||
if (isDev) {
|
||||
try {
|
||||
const resolved = resolveTargetFromRequest(_req);
|
||||
payload.details = {
|
||||
message,
|
||||
code: err && typeof err === 'object' ? err.code : undefined,
|
||||
targetOrigin: resolved?.ok ? resolved.entry.origin : undefined,
|
||||
};
|
||||
} catch {
|
||||
payload.details = { message };
|
||||
}
|
||||
}
|
||||
|
||||
res.status(502).json(payload);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.use('/api/preview/proxy', (req, res, next) => {
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return res.status(resolved.status).json({ error: resolved.error });
|
||||
}
|
||||
next();
|
||||
}, proxy);
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
if (uiAuthController?.enabled) {
|
||||
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
|
||||
if (!sessionToken) {
|
||||
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const originAllowed = await isRequestOriginAllowed(req);
|
||||
if (!originAllowed) {
|
||||
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite req.url to what the dev server expects.
|
||||
const rawUrl = req.url || '';
|
||||
const parsed = new URL(rawUrl, 'http://localhost');
|
||||
const nextPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||
const search = parsed.searchParams.toString();
|
||||
req.url = `${nextPath}${search ? `?${search}` : ''}`;
|
||||
proxy.upgrade(req, socket, head);
|
||||
} catch {
|
||||
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
|
||||
}
|
||||
};
|
||||
|
||||
void handleUpgrade();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
attach,
|
||||
};
|
||||
};
|
||||
@@ -192,6 +192,28 @@ export function createTerminalRuntime({
|
||||
},
|
||||
};
|
||||
|
||||
const killTerminalProcess = (ptyProcess, mode = 'term') => {
|
||||
if (!ptyProcess) return;
|
||||
|
||||
// Best-effort: try killing the process group first so child processes
|
||||
// started by shells (e.g. preview dev servers) don't orphan.
|
||||
if (process.platform !== 'win32') {
|
||||
const pid = ptyProcess.pid;
|
||||
if (typeof pid === 'number' && Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(-pid, mode === 'kill' ? 'SIGKILL' : 'SIGTERM');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// node-pty accepts an optional signal string; bun-pty ignores extra args.
|
||||
ptyProcess.kill(mode === 'kill' ? 'SIGKILL' : undefined);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const sendTerminalInputWsControl = (socket, payload) => {
|
||||
if (!socket || socket.readyState !== 1) {
|
||||
return;
|
||||
@@ -453,7 +475,7 @@ export function createTerminalRuntime({
|
||||
if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) {
|
||||
console.log(`Cleaning up idle terminal session: ${sessionId}`);
|
||||
try {
|
||||
session.ptyProcess.kill();
|
||||
killTerminalProcess(session.ptyProcess, 'term');
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
@@ -652,7 +674,7 @@ export function createTerminalRuntime({
|
||||
}
|
||||
|
||||
try {
|
||||
session.ptyProcess.kill();
|
||||
killTerminalProcess(session.ptyProcess, 'term');
|
||||
terminalSessions.delete(sessionId);
|
||||
console.log(`Closed terminal session: ${sessionId}`);
|
||||
res.json({ success: true });
|
||||
@@ -673,7 +695,7 @@ export function createTerminalRuntime({
|
||||
const existingSession = terminalSessions.get(sessionId);
|
||||
if (existingSession) {
|
||||
try {
|
||||
existingSession.ptyProcess.kill();
|
||||
killTerminalProcess(existingSession.ptyProcess, 'term');
|
||||
} catch (error) {
|
||||
}
|
||||
terminalSessions.delete(sessionId);
|
||||
@@ -731,7 +753,7 @@ export function createTerminalRuntime({
|
||||
const session = terminalSessions.get(sessionId);
|
||||
if (session) {
|
||||
try {
|
||||
session.ptyProcess.kill();
|
||||
killTerminalProcess(session.ptyProcess, 'kill');
|
||||
} catch (error) {
|
||||
}
|
||||
terminalSessions.delete(sessionId);
|
||||
@@ -741,7 +763,7 @@ export function createTerminalRuntime({
|
||||
for (const [id, session] of terminalSessions) {
|
||||
if (session.cwd === cwd) {
|
||||
try {
|
||||
session.ptyProcess.kill();
|
||||
killTerminalProcess(session.ptyProcess, 'kill');
|
||||
} catch (error) {
|
||||
}
|
||||
terminalSessions.delete(id);
|
||||
@@ -751,7 +773,7 @@ export function createTerminalRuntime({
|
||||
} else {
|
||||
for (const [id, session] of terminalSessions) {
|
||||
try {
|
||||
session.ptyProcess.kill();
|
||||
killTerminalProcess(session.ptyProcess, 'kill');
|
||||
} catch (error) {
|
||||
}
|
||||
terminalSessions.delete(id);
|
||||
@@ -770,7 +792,7 @@ export function createTerminalRuntime({
|
||||
|
||||
for (const [sessionId, session] of terminalSessions.entries()) {
|
||||
try {
|
||||
session.ptyProcess.kill();
|
||||
killTerminalProcess(session.ptyProcess, 'kill');
|
||||
} catch {
|
||||
}
|
||||
terminalSessions.delete(sessionId);
|
||||
|
||||
@@ -138,7 +138,12 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
if (options?.optional) {
|
||||
params.set('optional', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`, {
|
||||
cache: options?.optional ? 'no-store' : 'default',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
|
||||
Reference in New Issue
Block a user