mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 13:15:38 +08:00
Compare commits
18 Commits
fix/router
...
fix/file-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
762ecb0c3b | ||
|
|
ef2fd48b46 | ||
|
|
9e608b8426 | ||
|
|
c667b6a179 | ||
|
|
c7938e4f2b | ||
|
|
fa9eaf5573 | ||
|
|
2edfef2e3f | ||
|
|
96b16b42e4 | ||
|
|
f082cdc63b | ||
|
|
a9fa6eb6b6 | ||
|
|
37d363c1aa | ||
|
|
4658a97952 | ||
|
|
d9e9df183f | ||
|
|
43c33d5cb1 | ||
|
|
b988e0da51 | ||
|
|
f132a21cd7 | ||
|
|
137c7c4f3c | ||
|
|
153f1e54b4 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -3,6 +3,25 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
|
||||||
|
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
|
||||||
|
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
|
||||||
|
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
|
||||||
|
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
|
||||||
|
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
|
||||||
|
|
||||||
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
|
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
# CloudCLI UI Nginx subpath deployment template.
|
|
||||||
#
|
|
||||||
# Purpose:
|
|
||||||
# Serve CloudCLI UI from a path prefix such as:
|
|
||||||
# http://localhost/ai/
|
|
||||||
# https://example.com/ai/
|
|
||||||
#
|
|
||||||
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
|
||||||
# http://127.0.0.1:3001/
|
|
||||||
#
|
|
||||||
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
|
||||||
# remaining path to CloudCLI. For example:
|
|
||||||
# /ai/ -> /
|
|
||||||
# /ai/session/abc -> /session/abc
|
|
||||||
# /ai/assets/index.js -> /assets/index.js
|
|
||||||
#
|
|
||||||
# Important Nginx limitation:
|
|
||||||
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
|
||||||
# The configurable variables below are still useful for proxy/filter values,
|
|
||||||
# but if you change /ai to a different subpath, also update every line marked:
|
|
||||||
# [SUBPATH LITERAL]
|
|
||||||
#
|
|
||||||
# To use a different subpath, replace these literal matchers:
|
|
||||||
# location = /ai
|
|
||||||
# location ^~ /ai/
|
|
||||||
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
|
||||||
#
|
|
||||||
# Recommended deployment shape:
|
|
||||||
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
|
||||||
# are also proxied because the current frontend still calls those endpoints
|
|
||||||
# with root-relative URLs.
|
|
||||||
|
|
||||||
worker_processes 1;
|
|
||||||
|
|
||||||
events {
|
|
||||||
# Maximum simultaneous connections handled by each worker process.
|
|
||||||
# The default is enough for local testing and small self-hosted deployments.
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
|
||||||
# This map gives us the right Connection header for both cases:
|
|
||||||
# Upgrade present -> "upgrade"
|
|
||||||
# Upgrade absent -> "close"
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
|
||||||
# add ssl_certificate / ssl_certificate_key lines.
|
|
||||||
listen 80 default_server;
|
|
||||||
|
|
||||||
# Use your real hostname in production, for example:
|
|
||||||
# server_name cloudcli.example.com;
|
|
||||||
server_name localhost 127.0.0.1;
|
|
||||||
|
|
||||||
# ---- User settings -------------------------------------------------
|
|
||||||
#
|
|
||||||
# Public path prefix where users access CloudCLI.
|
|
||||||
# Do not add a trailing slash.
|
|
||||||
#
|
|
||||||
# This variable can be used in redirects and response rewrites. It
|
|
||||||
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
|
||||||
# lines too if you change it.
|
|
||||||
set $cloudcli_subpath /ai;
|
|
||||||
|
|
||||||
# Private upstream URL where the CloudCLI server is listening.
|
|
||||||
# For a default local server this is usually http://127.0.0.1:3001.
|
|
||||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
|
||||||
|
|
||||||
# Allow larger file uploads through the code editor/project file APIs.
|
|
||||||
client_max_body_size 100m;
|
|
||||||
|
|
||||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
|
||||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
|
||||||
location = /ai {
|
|
||||||
return 301 $cloudcli_subpath/;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main prefixed CloudCLI UI route.
|
|
||||||
#
|
|
||||||
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
|
||||||
# $cloudcli_subpath.
|
|
||||||
location ^~ /ai/ {
|
|
||||||
# Strip the public subpath before proxying. CloudCLI expects to see
|
|
||||||
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
|
||||||
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
|
||||||
|
|
||||||
# Forward the rewritten request to the private CloudCLI server.
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
|
||||||
# browser reaches a socket endpoint under the subpath.
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
# Preserve useful request metadata for logs and future app support.
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
# Long-running agent and terminal sessions can stay open for a long
|
|
||||||
# time, so avoid closing idle proxied connections too aggressively.
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
|
|
||||||
# Disable gzip from the upstream response so sub_filter can inspect
|
|
||||||
# and rewrite HTML/JSON/JS response bodies.
|
|
||||||
proxy_set_header Accept-Encoding "";
|
|
||||||
|
|
||||||
# Rewrite browser-visible root-relative URLs so the runtime can
|
|
||||||
# discover that the app is mounted under the subpath.
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# href="/manifest.json" -> href="/ai/manifest.json"
|
|
||||||
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
|
||||||
#
|
|
||||||
# These rewrites are important for React Router basename detection.
|
|
||||||
sub_filter_once off;
|
|
||||||
sub_filter_types
|
|
||||||
application/json
|
|
||||||
application/manifest+json
|
|
||||||
application/javascript
|
|
||||||
text/javascript;
|
|
||||||
|
|
||||||
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
|
||||||
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
|
||||||
|
|
||||||
# The production HTML and JS register the service worker at /sw.js.
|
|
||||||
# Rewrite that registration so the worker is served from /ai/sw.js.
|
|
||||||
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
|
||||||
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
|
||||||
|
|
||||||
# The manifest and service worker contain root-relative paths too.
|
|
||||||
# Rewriting them keeps PWA metadata and cached manifest requests
|
|
||||||
# under the same public subpath.
|
|
||||||
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
|
||||||
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
|
||||||
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
|
||||||
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
|
||||||
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
|
||||||
}
|
|
||||||
|
|
||||||
# Root API proxy.
|
|
||||||
#
|
|
||||||
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
|
||||||
# as /api/auth/login. Keep this location unless the frontend becomes
|
|
||||||
# fully prefix-aware for API requests.
|
|
||||||
location ^~ /api/ {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main app WebSocket proxy.
|
|
||||||
#
|
|
||||||
# The frontend opens /ws for realtime chat/session/task updates.
|
|
||||||
location /ws {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Shell WebSocket proxy.
|
|
||||||
#
|
|
||||||
# The browser terminal uses /shell. It requires the same WebSocket
|
|
||||||
# upgrade handling as /ws.
|
|
||||||
location /shell {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optional health endpoint proxy used by the frontend version checker.
|
|
||||||
location = /health {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
792
package-lock.json
generated
792
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.32.0",
|
"version": "1.33.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-server/server/index.js",
|
"main": "dist-server/server/index.js",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"author": "CloudCLI UI Contributors",
|
"author": "CloudCLI UI Contributors",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.3",
|
||||||
|
"dompurify": "^3.4.7",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const CLAUDE_MODELS = {
|
|||||||
{
|
{
|
||||||
value: "default",
|
value: "default",
|
||||||
label: "Default (recommended)",
|
label: "Default (recommended)",
|
||||||
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
|
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "sonnet",
|
value: "sonnet",
|
||||||
|
|||||||
127
server/index.js
127
server/index.js
@@ -1483,25 +1483,76 @@ function permToRwx(perm) {
|
|||||||
return r + w + x;
|
return r + w + x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directories that are almost never interesting for a project tree but can
|
||||||
|
// contain tens of thousands of files. Skipping them before recursion keeps
|
||||||
|
// traversal time bounded on large monorepos and high-latency filesystems
|
||||||
|
// (NFS / SMB).
|
||||||
|
const IGNORED_DIRS = new Set([
|
||||||
|
// JS / TS toolchains
|
||||||
|
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
|
||||||
|
// VCS
|
||||||
|
'.git', '.svn', '.hg',
|
||||||
|
// Python
|
||||||
|
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
|
||||||
|
// Rust / Go / Java / Ruby
|
||||||
|
'target', 'vendor',
|
||||||
|
// Build output / IDE
|
||||||
|
'.gradle', '.idea', 'coverage', '.nyc_output'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_FS_CONCURRENCY = 64;
|
||||||
|
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
|
||||||
|
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
|
||||||
|
? parsedFsConcurrency
|
||||||
|
: DEFAULT_FS_CONCURRENCY;
|
||||||
|
let activeFsOperations = 0;
|
||||||
|
const pendingFsOperations = [];
|
||||||
|
|
||||||
|
async function acquire() {
|
||||||
|
if (activeFsOperations < FS_CONCURRENCY) {
|
||||||
|
activeFsOperations += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
pendingFsOperations.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
const next = pendingFsOperations.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeFsOperations = Math.max(0, activeFsOperations - 1);
|
||||||
|
}
|
||||||
|
|
||||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||||
// Using fsPromises from import
|
// Using fsPromises from import
|
||||||
const items = [];
|
let entries;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
await acquire();
|
||||||
|
try {
|
||||||
|
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Only log non-permission errors to avoid spam
|
||||||
|
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||||
|
console.error('Error reading directory:', error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
|
||||||
// Debug: log all entries including hidden files
|
|
||||||
|
|
||||||
|
|
||||||
// Skip heavy build directories and VCS directories
|
|
||||||
if (entry.name === 'node_modules' ||
|
|
||||||
entry.name === 'dist' ||
|
|
||||||
entry.name === 'build' ||
|
|
||||||
entry.name === '.git' ||
|
|
||||||
entry.name === '.svn' ||
|
|
||||||
entry.name === '.hg') continue;
|
|
||||||
|
|
||||||
|
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
|
||||||
|
// serial stat() was the real bottleneck — issuing them concurrently lets
|
||||||
|
// the kernel pipeline the round-trips and the recursive calls overlap too.
|
||||||
|
const items = await Promise.all(filteredEntries.map(async (entry) => {
|
||||||
const itemPath = path.join(dirPath, entry.name);
|
const itemPath = path.join(dirPath, entry.name);
|
||||||
const item = {
|
const item = {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -1511,17 +1562,33 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
|||||||
|
|
||||||
// Get file stats for additional metadata
|
// Get file stats for additional metadata
|
||||||
try {
|
try {
|
||||||
const stats = await fsPromises.stat(itemPath);
|
await acquire();
|
||||||
|
try {
|
||||||
|
const stats = await fsPromises.lstat(itemPath);
|
||||||
item.size = stats.size;
|
item.size = stats.size;
|
||||||
item.modified = stats.mtime.toISOString();
|
item.modified = stats.mtime.toISOString();
|
||||||
|
|
||||||
|
// Mark symlinks so UI can distinguish them
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
item.isSymlink = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert permissions to rwx format
|
// Convert permissions to rwx format
|
||||||
const mode = stats.mode;
|
const mode = stats.mode;
|
||||||
const ownerPerm = (mode >> 6) & 7;
|
const ownerPerm = (mode >> 6) & 7;
|
||||||
const groupPerm = (mode >> 3) & 7;
|
const groupPerm = (mode >> 3) & 7;
|
||||||
const otherPerm = mode & 7;
|
const otherPerm = mode & 7;
|
||||||
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
item.permissions =
|
||||||
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
((mode >> 6) & 7).toString() +
|
||||||
|
((mode >> 3) & 7).toString() +
|
||||||
|
(mode & 7).toString();
|
||||||
|
item.permissionsRwx =
|
||||||
|
permToRwx(ownerPerm) +
|
||||||
|
permToRwx(groupPerm) +
|
||||||
|
permToRwx(otherPerm);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
} catch (statError) {
|
} catch (statError) {
|
||||||
// If stat fails, provide default values
|
// If stat fails, provide default values
|
||||||
item.size = 0;
|
item.size = 0;
|
||||||
@@ -1531,25 +1598,17 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||||
// Recursively get subdirectories but limit depth
|
// Recurse. Let readdir's own EACCES bubble up through the catch in
|
||||||
try {
|
// the recursive call rather than doing a separate access() probe
|
||||||
// Check if we can access the directory before trying to read it
|
// (which doubled the round-trip count on SMB without adding info).
|
||||||
await fsPromises.access(item.path, fs.constants.R_OK);
|
// The recursive call starts with a bounded readdir; holding a permit
|
||||||
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
// for the whole subtree can deadlock when sibling directories are
|
||||||
} catch (e) {
|
// waiting on their own children.
|
||||||
// Silently skip directories we can't access (permission denied, etc.)
|
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
|
||||||
item.children = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(item);
|
return item;
|
||||||
}
|
}));
|
||||||
} catch (error) {
|
|
||||||
// Only log non-permission errors to avoid spam
|
|
||||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
|
||||||
console.error('Error reading directory:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
|||||||
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
||||||
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
|
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
|
||||||
|
|
||||||
|
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
||||||
|
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type ShellIncomingMessage = {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
initialCommand?: string;
|
initialCommand?: string;
|
||||||
isPlainShell?: boolean;
|
isPlainShell?: boolean;
|
||||||
|
forceRestart?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PtySessionEntry = {
|
type PtySessionEntry = {
|
||||||
@@ -180,6 +181,7 @@ export function handleShellConnection(
|
|||||||
const hasSession = readBoolean(data.hasSession);
|
const hasSession = readBoolean(data.hasSession);
|
||||||
const provider = readString(data.provider, 'claude');
|
const provider = readString(data.provider, 'claude');
|
||||||
const initialCommand = readString(data.initialCommand);
|
const initialCommand = readString(data.initialCommand);
|
||||||
|
const forceRestart = readBoolean(data.forceRestart);
|
||||||
const isPlainShell =
|
const isPlainShell =
|
||||||
readBoolean(data.isPlainShell) ||
|
readBoolean(data.isPlainShell) ||
|
||||||
(!!initialCommand && !hasSession) ||
|
(!!initialCommand && !hasSession) ||
|
||||||
@@ -200,7 +202,7 @@ export function handleShellConnection(
|
|||||||
: '';
|
: '';
|
||||||
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
||||||
|
|
||||||
if (isLoginCommand) {
|
if (isLoginCommand || forceRestart) {
|
||||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||||
if (oldSession) {
|
if (oldSession) {
|
||||||
if (oldSession.timeoutId) {
|
if (oldSession.timeoutId) {
|
||||||
@@ -211,7 +213,8 @@ export function handleShellConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
const existingSession =
|
||||||
|
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
shellProcess = existingSession.pty;
|
shellProcess = existingSession.pty;
|
||||||
if (existingSession.timeoutId) {
|
if (existingSession.timeoutId) {
|
||||||
@@ -368,6 +371,10 @@ export function handleShellConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = ptySessionsMap.get(ptySessionKey);
|
const session = ptySessionsMap.get(ptySessionKey);
|
||||||
|
if (session && session.pty !== shellProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||||
session.ws.send(
|
session.ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -451,6 +458,10 @@ export function handleShellConnection(
|
|||||||
|
|
||||||
session.ws = null;
|
session.ws = null;
|
||||||
session.timeoutId = setTimeout(() => {
|
session.timeoutId = setTimeout(() => {
|
||||||
|
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
session.pty.kill();
|
session.pty.kill();
|
||||||
ptySessionsMap.delete(ptySessionKey as string);
|
ptySessionsMap.delete(ptySessionKey as string);
|
||||||
}, PTY_SESSION_TIMEOUT);
|
}, PTY_SESSION_TIMEOUT);
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ export function createWebSocketServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
wss.on('connection', (ws, request) => {
|
wss.on('connection', (ws, request) => {
|
||||||
|
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
|
||||||
|
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
|
||||||
|
// are silently torn down even when the UI is active, causing repeated
|
||||||
|
// reconnect cycles. ws library heartbeat is opt-in.
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
try {
|
||||||
|
ws.ping();
|
||||||
|
} catch {
|
||||||
|
// socket may have been closed concurrently — interval will be cleared below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
const stopHeartbeat = () => clearInterval(heartbeat);
|
||||||
|
ws.on('close', stopHeartbeat);
|
||||||
|
ws.on('error', stopHeartbeat);
|
||||||
|
|
||||||
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
||||||
const url = incomingRequest.url ?? '/';
|
const url = incomingRequest.url ?? '/';
|
||||||
const pathname = new URL(url, 'http://localhost').pathname;
|
const pathname = new URL(url, 'http://localhost').pathname;
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
dir="auto"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
onClick={onTextareaClick}
|
onClick={onTextareaClick}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
/* User message bubble on the right */
|
/* User message bubble on the right */
|
||||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||||
<div className="whitespace-pre-wrap break-words text-sm">
|
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{message.images && message.images.length > 0 && (
|
{message.images && message.images.length > 0 && (
|
||||||
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
</ReasoningContent>
|
</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{/* Reasoning accordion */}
|
{/* Reasoning accordion */}
|
||||||
{showThinking && message.reasoning && (
|
{showThinking && message.reasoning && (
|
||||||
<Reasoning className="mb-3" defaultOpen={false}>
|
<Reasoning className="mb-3" defaultOpen={false}>
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
|
|
||||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
|
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
|
||||||
<Trans
|
<Trans
|
||||||
|
ns="chat"
|
||||||
i18nKey="providerSelection.pressToSearch"
|
i18nKey="providerSelection.pressToSearch"
|
||||||
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,6 +12,48 @@ type Props = {
|
|||||||
// Module-level cache so repeated renders don't re-fetch
|
// Module-level cache so repeated renders don't re-fetch
|
||||||
const svgCache = new Map<string, string>();
|
const svgCache = new Map<string, string>();
|
||||||
|
|
||||||
|
const FORBIDDEN_SVG_TAGS = [
|
||||||
|
'script',
|
||||||
|
'foreignObject',
|
||||||
|
'iframe',
|
||||||
|
'object',
|
||||||
|
'embed',
|
||||||
|
'link',
|
||||||
|
'meta',
|
||||||
|
'style',
|
||||||
|
'animate',
|
||||||
|
'set',
|
||||||
|
'animateTransform',
|
||||||
|
'animateMotion',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORBIDDEN_SVG_ATTRS = [
|
||||||
|
'href',
|
||||||
|
'xlink:href',
|
||||||
|
'src',
|
||||||
|
'style',
|
||||||
|
];
|
||||||
|
|
||||||
|
function sanitizeSvg(svgText: string): string | null {
|
||||||
|
const sanitized = DOMPurify.sanitize(svgText, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true },
|
||||||
|
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
|
||||||
|
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sanitized) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
|
||||||
|
const root = doc.documentElement;
|
||||||
|
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
|
||||||
|
if (doc.querySelector('parsererror')) return null;
|
||||||
|
return sanitized;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||||
const url = iconFile
|
const url = iconFile
|
||||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||||
@@ -24,9 +68,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
|||||||
return r.text();
|
return r.text();
|
||||||
})
|
})
|
||||||
.then((text) => {
|
.then((text) => {
|
||||||
if (text && text.trimStart().startsWith('<svg')) {
|
if (!text) return;
|
||||||
svgCache.set(url, text);
|
const sanitized = sanitizeSvg(text);
|
||||||
setSvg(text);
|
if (sanitized) {
|
||||||
|
svgCache.set(url, sanitized);
|
||||||
|
setSvg(sanitized);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -35,10 +81,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
|||||||
if (!svg) return <span className={className} />;
|
if (!svg) return <span className={className} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
className={className}
|
|
||||||
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
|
|
||||||
dangerouslySetInnerHTML={{ __html: svg }}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
||||||
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
||||||
@@ -31,8 +32,8 @@ type UseShellConnectionResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
connectToShell: () => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: () => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useShellConnection({
|
export function useShellConnection({
|
||||||
@@ -54,6 +55,8 @@ export function useShellConnection({
|
|||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const connectingRef = useRef(false);
|
const connectingRef = useRef(false);
|
||||||
|
const forceRestartOnInitRef = useRef(false);
|
||||||
|
const suppressAutoConnectRef = useRef(false);
|
||||||
|
|
||||||
const handleProcessCompletion = useCallback(
|
const handleProcessCompletion = useCallback(
|
||||||
(output: string) => {
|
(output: string) => {
|
||||||
@@ -141,6 +144,8 @@ export function useShellConnection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentFitAddon.fit();
|
currentFitAddon.fit();
|
||||||
|
const forceRestart = forceRestartOnInitRef.current;
|
||||||
|
forceRestartOnInitRef.current = false;
|
||||||
|
|
||||||
sendSocketMessage(socket, {
|
sendSocketMessage(socket, {
|
||||||
type: 'init',
|
type: 'init',
|
||||||
@@ -152,6 +157,7 @@ export function useShellConnection({
|
|||||||
rows: currentTerminal.rows,
|
rows: currentTerminal.rows,
|
||||||
initialCommand: initialCommandRef.current,
|
initialCommand: initialCommandRef.current,
|
||||||
isPlainShell: isPlainShellRef.current,
|
isPlainShell: isPlainShellRef.current,
|
||||||
|
forceRestart,
|
||||||
});
|
});
|
||||||
}, TERMINAL_INIT_DELAY_MS);
|
}, TERMINAL_INIT_DELAY_MS);
|
||||||
};
|
};
|
||||||
@@ -177,6 +183,7 @@ export function useShellConnection({
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
|
forceRestartOnInitRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -195,27 +202,40 @@ export function useShellConnection({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectToShell = useCallback(() => {
|
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => {
|
||||||
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
|
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
|
||||||
|
suppressAutoConnectRef.current = false;
|
||||||
connectingRef.current = true;
|
connectingRef.current = true;
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
connectWebSocket(true);
|
connectWebSocket(true);
|
||||||
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
||||||
|
|
||||||
const disconnectFromShell = useCallback(() => {
|
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
|
||||||
|
if (options?.suppressAutoConnect) {
|
||||||
|
suppressAutoConnectRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
closeSocket();
|
closeSocket();
|
||||||
clearTerminalScreen();
|
clearTerminalScreen();
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
|
forceRestartOnInitRef.current = false;
|
||||||
setAuthUrl('');
|
setAuthUrl('');
|
||||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
|
if (
|
||||||
|
!autoConnect ||
|
||||||
|
suppressAutoConnectRef.current ||
|
||||||
|
!isInitialized ||
|
||||||
|
isConnecting ||
|
||||||
|
isConnected
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { MutableRefObject, RefObject } from 'react';
|
import type { MutableRefObject, RefObject } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||||
@@ -15,6 +16,7 @@ export type ShellInitMessage = {
|
|||||||
rows: number;
|
rows: number;
|
||||||
initialCommand: string | null | undefined;
|
initialCommand: string | null | undefined;
|
||||||
isPlainShell: boolean;
|
isPlainShell: boolean;
|
||||||
|
forceRestart?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShellResizeMessage = {
|
export type ShellResizeMessage = {
|
||||||
@@ -69,8 +71,8 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
authUrl: string;
|
authUrl: string;
|
||||||
authUrlVersion: number;
|
authUrlVersion: number;
|
||||||
connectToShell: () => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: () => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import { useShellRuntime } from '../hooks/useShellRuntime';
|
import { useShellRuntime } from '../hooks/useShellRuntime';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { getSessionDisplayName } from '../utils/auth';
|
import { getSessionDisplayName } from '../utils/auth';
|
||||||
|
|
||||||
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
||||||
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
||||||
import ShellHeader from './subcomponents/ShellHeader';
|
import ShellHeader from './subcomponents/ShellHeader';
|
||||||
@@ -46,6 +48,8 @@ export default function Shell({
|
|||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
|
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
|
||||||
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const restartAfterInitRef = useRef(false);
|
||||||
const onOutputRef = useRef<(() => void) | null>(null);
|
const onOutputRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -140,6 +144,7 @@ export default function Shell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
|
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
|
||||||
|
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -190,12 +195,42 @@ export default function Shell({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRestartShell = useCallback(() => {
|
const handleRestartShell = useCallback(() => {
|
||||||
|
restartAfterInitRef.current = true;
|
||||||
setIsRestarting(true);
|
setIsRestarting(true);
|
||||||
window.setTimeout(() => {
|
if (restartTimerRef.current) {
|
||||||
|
clearTimeout(restartTimerRef.current);
|
||||||
|
}
|
||||||
|
restartTimerRef.current = setTimeout(() => {
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
|
restartTimerRef.current = null;
|
||||||
}, SHELL_RESTART_DELAY_MS);
|
}, SHELL_RESTART_DELAY_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDisconnectShell = useCallback(() => {
|
||||||
|
restartAfterInitRef.current = false;
|
||||||
|
if (restartTimerRef.current) {
|
||||||
|
clearTimeout(restartTimerRef.current);
|
||||||
|
restartTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsRestarting(false);
|
||||||
|
disconnectFromShell({ suppressAutoConnect: true });
|
||||||
|
}, [disconnectFromShell]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!restartAfterInitRef.current ||
|
||||||
|
isRestarting ||
|
||||||
|
!isInitialized ||
|
||||||
|
isConnected ||
|
||||||
|
isConnecting
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restartAfterInitRef.current = false;
|
||||||
|
connectToShell({ forceRestart: true });
|
||||||
|
}, [connectToShell, isConnected, isConnecting, isInitialized, isRestarting]);
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
<ShellEmptyState
|
<ShellEmptyState
|
||||||
@@ -254,7 +289,7 @@ export default function Shell({
|
|||||||
isRestarting={isRestarting}
|
isRestarting={isRestarting}
|
||||||
hasSession={Boolean(selectedSession)}
|
hasSession={Boolean(selectedSession)}
|
||||||
sessionDisplayNameShort={sessionDisplayNameShort}
|
sessionDisplayNameShort={sessionDisplayNameShort}
|
||||||
onDisconnect={disconnectFromShell}
|
onDisconnect={handleDisconnectShell}
|
||||||
onRestart={handleRestartShell}
|
onRestart={handleRestartShell}
|
||||||
statusNewSessionText={t('shell.status.newSession')}
|
statusNewSessionText={t('shell.status.newSession')}
|
||||||
statusInitializingText={t('shell.status.initializing')}
|
statusInitializingText={t('shell.status.initializing')}
|
||||||
@@ -263,7 +298,7 @@ export default function Shell({
|
|||||||
disconnectTitle={t('shell.actions.disconnectTitle')}
|
disconnectTitle={t('shell.actions.disconnectTitle')}
|
||||||
restartLabel={t('shell.actions.restart')}
|
restartLabel={t('shell.actions.restart')}
|
||||||
restartTitle={t('shell.actions.restartTitle')}
|
restartTitle={t('shell.actions.restartTitle')}
|
||||||
disableRestart={isRestarting || isConnected}
|
disableRestart={isRestarting || !isInitialized}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex-1 overflow-hidden p-2">
|
<div className="relative flex-1 overflow-hidden p-2">
|
||||||
@@ -281,7 +316,7 @@ export default function Shell({
|
|||||||
connectLabel={t('shell.actions.connect')}
|
connectLabel={t('shell.actions.connect')}
|
||||||
connectTitle={t('shell.actions.connectTitle')}
|
connectTitle={t('shell.actions.connectTitle')}
|
||||||
connectingLabel={t('shell.connecting')}
|
connectingLabel={t('shell.connecting')}
|
||||||
onConnect={connectToShell}
|
onConnect={handleRestartShell}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
type ShellConnectionOverlayProps = {
|
type ShellConnectionOverlayProps = {
|
||||||
mode: 'loading' | 'connect' | 'connecting';
|
mode: 'loading' | 'connect' | 'connecting';
|
||||||
description: string;
|
description: string;
|
||||||
@@ -19,40 +21,42 @@ export default function ShellConnectionOverlay({
|
|||||||
}: ShellConnectionOverlayProps) {
|
}: ShellConnectionOverlayProps) {
|
||||||
if (mode === 'loading') {
|
if (mode === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90">
|
||||||
<div className="text-white">{loadingLabel}</div>
|
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
|
||||||
|
<span>{loadingLabel}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'connect') {
|
if (mode === 'connect') {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
||||||
<div className="w-full max-w-sm text-center">
|
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onConnect}
|
onClick={onConnect}
|
||||||
className="flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto"
|
className="pointer-events-auto inline-flex min-h-12 w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-md bg-emerald-600 px-5 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-950/30 transition-colors hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2 focus:ring-offset-gray-950 active:bg-emerald-700"
|
||||||
title={connectTitle}
|
title={connectTitle}
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<span className="min-w-0 truncate">{connectLabel}</span>
|
||||||
</svg>
|
|
||||||
<span>{connectLabel}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
|
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
||||||
<div className="w-full max-w-sm text-center">
|
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
||||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
<div className="flex items-center justify-center gap-3 text-yellow-300">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
|
||||||
<span className="text-base font-medium">{connectingLabel}</span>
|
<span className="text-base font-medium">{connectingLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
|
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { RotateCcw, X } from 'lucide-react';
|
||||||
|
|
||||||
type ShellHeaderProps = {
|
type ShellHeaderProps = {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
@@ -50,34 +52,27 @@ export default function ShellHeader({
|
|||||||
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center gap-2">
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onDisconnect}
|
onClick={onDisconnect}
|
||||||
className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
|
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-red-600 px-3 text-xs font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||||
title={disconnectTitle}
|
title={disconnectTitle}
|
||||||
>
|
>
|
||||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
<span>{disconnectLabel}</span>
|
<span>{disconnectLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onRestart}
|
onClick={onRestart}
|
||||||
disabled={disableRestart}
|
disabled={disableRestart}
|
||||||
className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-gray-600/80 bg-gray-700/70 px-3 text-xs font-medium text-gray-100 transition-colors hover:border-blue-400/70 hover:bg-blue-600/80 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-400/70 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-gray-500 disabled:opacity-60"
|
||||||
title={restartTitle}
|
title={restartTitle}
|
||||||
>
|
>
|
||||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{restartLabel}</span>
|
<span>{restartLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// The cleanup below sets unmountedRef = true. Without this reset, every
|
||||||
|
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
|
||||||
|
// at its unmounted guard and leave the socket permanently disconnected.
|
||||||
|
unmountedRef.current = false;
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"disconnectTitle": "Disconnect from shell",
|
"disconnectTitle": "Disconnect from shell",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"restartTitle": "Restart Shell (disconnect first)",
|
"restartTitle": "Restart Shell",
|
||||||
"connect": "Continue in Shell",
|
"connect": "Continue in Shell",
|
||||||
"connectTitle": "Connect to shell"
|
"connectTitle": "Connect to shell"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
'/shell': {
|
'/shell': {
|
||||||
target: `ws://${proxyHost}:${serverPort}`,
|
target: `ws://${proxyHost}:${serverPort}`,
|
||||||
ws: true
|
ws: true
|
||||||
|
},
|
||||||
|
'/plugin-ws': {
|
||||||
|
target: `ws://${proxyHost}:${serverPort}`,
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user