From c0353b6125eb373e58922c258c476c0a444c0c59 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 22:20:40 +0800 Subject: [PATCH 1/3] feat(docker): open container shells in native Lithe terminals --- README.md | 22 +++- backend/src/main.rs | 1 + backend/src/service.rs | 18 ++- backend/src/terminal.rs | 242 +++++++++++++++++++++++++++++++++++ e2e/docker_plugin.rs | 46 ++++++- e2e/lithe-core.json | 2 +- plugin.json | 3 +- shared/i18n/en-US.json | 15 ++- shared/i18n/zh-CN.json | 15 ++- ui/src/ContainerTerminal.tsx | 39 ++++++ ui/src/details.tsx | 11 +- ui/src/icons.tsx | 3 +- ui/src/main.tsx | 2 +- ui/src/style.css | 12 +- 14 files changed, 413 insertions(+), 18 deletions(-) create mode 100644 backend/src/terminal.rs create mode 100644 ui/src/ContainerTerminal.tsx diff --git a/README.md b/README.md index fe4e714..f081a56 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ Lithe 会以「插件包不能包含别名、符号链接或特殊文件」拒 - 详情采用分组卡片与虚拟表格,显示实际 inspect 标签、端口、挂载和网络信息。 - 日志支持搜索、自动换行与横向滚动;HTTP(S) 链接标蓝并通过宿主打开系统浏览器。 - 容器文件页支持目录导航、拖入普通文件上传、原生选择文件上传,以及选定文件下载。 +- 容器详情提供「打开终端」,经 API-24 打开 Lithe 原生终端,选择 `/bin/sh`、`/bin/bash` 或 `/bin/ash` 进入运行中的容器。 Compose 分组动作仅作用于已存在的容器,并不执行 `docker compose up/down`, -不解析 Compose 文件,也不编排服务依赖。尚不支持构建镜像、交互式终端、 +不解析 Compose 文件,也不编排服务依赖。尚不支持构建镜像、 私有仓库登录、SSH/TLS/TCP 远端或 Dev Containers。 ## 连接与数据 @@ -87,6 +88,19 @@ Elastic 自有仓库、Open WebUI、MinIO 和 pgvector 使用确切公开仓库 这些状态不跨工作树复用,也不写入安装包。浏览器打开复用 `browser.open` 宿主权限, 清单使用 `backend:2`;旧宿主会拒绝不支持的能力,不绕过 Web 页面导航限制。 +交互终端需要支持 API-24 的本机 macOS Lithe 工作区,以及终端 PATH 中可用的 Docker CLI; +所选 Shell 必须已安装在容器内。插件声明 `terminal.create` 权限,通过后端能力发现调用 +`lithe/host/terminal/create`,以独立 executable/argv 执行 +`docker --host unix://<当前 socket> exec --interactive --tty <容器 ID> `。 +不拼接宿主 Shell 命令,也不向已有用户终端注入文字。容器暂停或停止时不能打开。 +创建和重开由 Lithe 显示原生执行确认;取消后不自动重试。 +相同工作区、引擎、容器与 Shell 再次点击会显示已有终端,关闭后再次点击通过 `reopen` +启动新进程并恢复最多 16 KiB 显示历史,不重放输入,也不恢复 Shell 变量或旧进程。 +关闭按钮通过 `dispose` 释放该 Shell 的终端和历史;删除容器/Compose 分组时也先释放对应句柄。 +终端与句柄只保存在宿主和后端内存中,后端结束、禁用插件或关闭工作区由宿主清理; +网页关闭只取消在途操作,已交付的终端继续由用户使用。未增加磁盘缓存或安装目录写入。 +浏览器预览不具备 Lithe 原生终端服务,点击时明确提示不可用。 + ## 开发与验证 需要 Node.js 24+、Rust 1.87+ 和 macOS。官方 Rust SDK 锁定在 @@ -128,9 +142,11 @@ node scripts/e2e.mjs 该场景读取现有资源,创建专用临时容器,验证日志、统计、进程、暂停、恢复、 重启、停止、启动、删除、中文文件名分块上传/下载、原生选中文件上传对应的后端路径、 -取消传输清理、浏览器请求的 Core 权限/上下文路由、连接持久化及包文件不变性。 +取消传输清理、浏览器与 API-24 终端请求的 Core 权限/上下文路由、停止容器拒绝终端、连接持久化及包文件不变性。 浏览器实际启动、原生选择/保存对话框和 Finder 拖入仍应在目标 Lithe 宿主验收, -Core 测试只验证请求交给宿主,不伪造或宣称浏览器已启动。测试完成后删除临时容器。 +Core 测试只验证请求交给宿主,不伪造或宣称浏览器或原生终端已启动;无原生窗口的测试宿主对终端请求明确返回不可用。 +安装包内的终端仍需在最新 Lithe 验收:打开/重复显示、输入、Ctrl-C、缩放、关闭后重开历史、取消确认、释放和禁用后无残留进程。 +测试完成后删除临时容器。 不要在包含真实数据的引擎上执行全局清理测试;本场景不调用全局 prune。 所有插件自有界面文案来自 `shared/i18n/`;Docker 返回的诊断与资源名保持原文。 diff --git a/backend/src/main.rs b/backend/src/main.rs index 3c6b34f..61a03ca 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -10,6 +10,7 @@ mod files; mod http; mod logos; mod service; +mod terminal; mod time; use lithe_plugin_sdk::Backend; diff --git a/backend/src/service.rs b/backend/src/service.rs index 17cd4d2..cb544da 100644 --- a/backend/src/service.rs +++ b/backend/src/service.rs @@ -34,6 +34,7 @@ pub struct Service { terminals: Mutex>, logos: crate::logos::Logos, files: crate::files::Files, + native_terminals: crate::terminal::Terminals, } impl Service { @@ -46,6 +47,7 @@ impl Service { Arc::new(Self { logos: crate::logos::Logos::new(state.clone()), files: crate::files::Files::new(state.clone()), + native_terminals: crate::terminal::Terminals::default(), state, environment, choice: Mutex::new(choice), @@ -61,6 +63,8 @@ impl Service { }); let functions: &[(&str, Handler)] = &[ ("browser.open", Self::open_browser), + ("containers.terminal", Self::open_terminal), + ("containers.terminalDispose", Self::dispose_terminal), ("files.list", Self::files_list), ("files.uploadBegin", Self::upload_begin), ("files.uploadLocal", Self::upload_local), @@ -108,6 +112,15 @@ impl Service { self.logos.get(text(¶ms, "image")?, context) } + fn open_terminal(&self, params: Value, context: &Context) -> Result { + self.native_terminals.open(&self.docker()?, ¶ms, context) + } + + fn dispose_terminal(&self, params: Value, context: &Context) -> Result { + self.native_terminals + .dispose(&self.endpoint()?.socket, ¶ms, context) + } + fn open_browser(&self, params: Value, context: &Context) -> Result { let url = text(¶ms, "url")?; let parsed = reqwest::Url::parse(url).map_err(|_| invalid("Invalid web URL."))?; @@ -262,7 +275,9 @@ impl Service { flag(¶ms, "force"), flag(¶ms, "volumes") ); - self.docker()?.call(context, "DELETE", &target, None)?; + let docker = self.docker()?; + self.native_terminals.dispose_container(&docker.socket, id, context)?; + docker.call(context, "DELETE", &target, None)?; lock(&self.terminals).retain(|(_, container), _| container != id); Ok(Value::Null) } @@ -393,6 +408,7 @@ impl Service { let percentage = (index * 100 / containers.len().max(1)) as u8; context.progress(&format!("{} {name}", capital(action)), Some(percentage)); if action == "remove" { + self.native_terminals.dispose_container(&docker.socket, id, context)?; docker.call(context, "DELETE", &format!("/containers/{}?force=1", encode(id)), None)?; lock(&self.terminals).retain(|(_, container), _| container != id); } else { diff --git a/backend/src/terminal.rs b/backend/src/terminal.rs new file mode 100644 index 0000000..c65f770 --- /dev/null +++ b/backend/src/terminal.rs @@ -0,0 +1,242 @@ +//! Container shells use API-24's native terminal service, never a web PTY or +//! shell command injection. Handles and launch history remain host-owned and +//! memory-only; explicit socket/argv keep each shell on its selected engine. + +use crate::docker::{encode, Docker}; +use lithe_plugin_sdk::{Context, FunctionError, PluginError, SdkError, Value, WorkspaceContext}; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, TryLockError}; + +const PREFIX: &str = "lithe/host/terminal/"; +const SHELLS: &[&str] = &["/bin/sh", "/bin/bash", "/bin/ash"]; +const HISTORY_BYTES: usize = 16 * 1024; + +#[derive(Clone, Hash, PartialEq, Eq)] +struct Key { + scope: WorkspaceContext, + socket: PathBuf, + container: String, + shell: String, +} + +#[derive(Default)] +pub struct Terminals { + // One operation at a time, with an immediate busy error instead of blocking + // SDK workers while another operation awaits the user's native consent. + handles: Mutex>, +} + +impl Terminals { + pub fn open(&self, docker: &Docker, params: &Value, context: &Context) -> Result { + let key = key(&docker.socket, params, context)?; + for method in ["create", "show", "reopen", "dispose"] { + if !context + .host() + .supports(&format!("{PREFIX}{method}")) + .map_err(host_error)? + { + return Err(error( + "docker.terminalUnavailable", + "This host does not provide native terminals.", + )); + } + } + let mut handles = self.handles()?; + // The host releases the old generation's terminals on context changes. + handles + .retain(|existing, _| existing.scope.context_id() != key.scope.context_id() || existing.scope == key.scope); + let inspected = docker.get(context, &format!("/containers/{}/json", encode(&key.container)))?; + if inspected["State"]["Running"] != true || inspected["State"]["Paused"] == true { + return Err(error( + "docker.terminalStopped", + "Start or resume this container before opening a terminal.", + )); + } + context.check_cancelled()?; + let host = context.host(); + if let Some(id) = handles.get(&key) { + let request = json!({ "context": key.scope.to_value(), "terminalID": id }); + match host.request(&format!("{PREFIX}show"), request.clone()) { + Ok(_) => return Ok(json!({ "terminalID": id })), + Err(failure) if failure.code() == "plugin.host.preconditionFailed" => { + // Reopen is explicit and still asks the host for execution + // consent. Never retry a cancelled create/reopen. + return host.request(&format!("{PREFIX}reopen"), request).map_err(host_error); + } + Err(failure) if failure.code() == "plugin.host.notFound" => { + handles.remove(&key); + } + Err(failure) => return Err(host_error(failure)), + } + } + // Inspect supplies the canonical ID: a caller's name beginning with '-' + // must never become another CLI option. The UI supplies only the title. + let container = inspected["Id"] + .as_str() + .filter(|id| id.len() == 64 && id.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| error("docker.terminalInvalid", "Docker did not return a valid container ID."))?; + let title = params["title"] + .as_str() + .ok_or_else(|| error("docker.invalidParams", "A localized terminal title is required."))?; + let launch = launch(&key.socket, container, &key.shell, title, &key.scope)?; + let created = host.request(&format!("{PREFIX}create"), launch).map_err(host_error)?; + let id = created["terminalID"] + .as_str() + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + error( + "docker.terminalInvalid", + "The native terminal service returned no handle.", + ) + })?; + handles.insert(key, id.to_string()); + Ok(created) + } + + pub fn dispose(&self, socket: &Path, params: &Value, context: &Context) -> Result { + let key = key(socket, params, context)?; + let mut handles = self.handles()?; + if let Some(id) = handles.get(&key) { + let request = json!({ "context": key.scope.to_value(), "terminalID": id }); + match context.host().request(&format!("{PREFIX}dispose"), request) { + Ok(_) => {} + Err(failure) if failure.code() == "plugin.host.notFound" => {} + Err(failure) => return Err(host_error(failure)), + } + handles.remove(&key); + } + Ok(Value::Null) + } + + /// Removing a container releases closed handles too, so its no-longer- + /// selectable row cannot permanently consume a host terminal slot. + pub fn dispose_container(&self, socket: &Path, container: &str, context: &Context) -> Result<(), FunctionError> { + let Some(scope) = context.workspace_context() else { + return Ok(()); + }; + let mut handles = self.handles()?; + let keys: Vec<_> = handles + .keys() + .filter(|key| key.scope == *scope && key.socket == socket && key.container == container) + .cloned() + .collect(); + for key in keys { + let id = &handles[&key]; + match context.host().request( + &format!("{PREFIX}dispose"), + json!({ + "context": scope.to_value(), "terminalID": id + }), + ) { + Ok(_) => {} + Err(failure) if failure.code() == "plugin.host.notFound" => {} + Err(failure) => return Err(host_error(failure)), + } + handles.remove(&key); + } + Ok(()) + } + + fn handles(&self) -> Result>, FunctionError> { + match self.handles.try_lock() { + Ok(handles) => Ok(handles), + Err(TryLockError::Poisoned(poisoned)) => Ok(poisoned.into_inner()), + Err(TryLockError::WouldBlock) => Err(error( + "plugin.host.busy", + "A terminal operation is already in progress.", + )), + } + } +} + +fn key(socket: &Path, params: &Value, context: &Context) -> Result { + let scope = context.workspace_context().ok_or_else(|| { + error( + "docker.terminalUnavailable", + "Native terminals require a Lithe workspace context.", + ) + })?; + let container = params["id"] + .as_str() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| error("docker.invalidParams", "A container ID is required."))?; + let shell = params["shell"].as_str().unwrap_or("/bin/sh"); + if !SHELLS.contains(&shell) { + return Err(error("docker.invalidParams", "Choose a supported container shell.")); + } + Ok(Key { + scope: scope.clone(), + socket: socket.into(), + container: container.into(), + shell: shell.into(), + }) +} + +fn launch( + socket: &Path, + container: &str, + shell: &str, + title: &str, + scope: &WorkspaceContext, +) -> Result { + let socket = socket + .to_str() + .filter(|socket| socket.starts_with('/') && !socket.contains('\0')) + .ok_or_else(|| { + error( + "docker.terminalInvalid", + "The Docker socket is not a valid absolute UTF-8 path.", + ) + })?; + Ok(json!({ + "context": scope.to_value(), "executable": "docker", + "arguments": ["--host", format!("unix://{socket}"), "exec", "--interactive", "--tty", container, shell], + "title": title, "show": true, "retainedContextBytes": HISTORY_BYTES, + // DOCKER_CONTEXT overrides DOCKER_HOST; inherited TLS configuration can + // also conflict with the explicitly selected local Unix socket. + "environment": { "DOCKER_CONTEXT": null, "DOCKER_HOST": null, "DOCKER_TLS": null, + "DOCKER_TLS_VERIFY": null, "DOCKER_CERT_PATH": null } + })) +} + +fn error(code: &str, message: &str) -> FunctionError { + PluginError::new(code, message).into() +} + +fn host_error(failure: SdkError) -> FunctionError { + match failure.code() { + "sdk.call.cancelled" | "requestCancelled" | "plugin.host.cancelled" => FunctionError::Cancelled, + _ => error(failure.code(), failure.message()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_pins_the_engine_and_preserves_argv_without_shell_expansion() { + let scope = WorkspaceContext::new("docker-test", 2).unwrap(); + let socket = Path::new("/tmp/docker socket;$(touch unexpected).sock"); + let request = launch(socket, &"a".repeat(64), "/bin/sh", "Docker · 容器", &scope).unwrap(); + assert_eq!(request["executable"], "docker"); + assert_eq!( + request["arguments"], + json!([ + "--host", + "unix:///tmp/docker socket;$(touch unexpected).sock", + "exec", + "--interactive", + "--tty", + "a".repeat(64), + "/bin/sh" + ]) + ); + assert_eq!(request["context"], scope.to_value()); + assert_eq!(request["environment"]["DOCKER_CONTEXT"], Value::Null); + assert_eq!(request["retainedContextBytes"], 16384); + assert!(launch(Path::new("relative.sock"), "id", "/bin/sh", "Docker", &scope).is_err()); + } +} diff --git a/e2e/docker_plugin.rs b/e2e/docker_plugin.rs index a07ebfb..bf1832e 100644 --- a/e2e/docker_plugin.rs +++ b/e2e/docker_plugin.rs @@ -25,7 +25,7 @@ fn packaged_backend_manages_a_real_container_without_writing_the_package() { let mut store = BTreeMap::new(); let mut host = Host::start(plugin_id, &package, &plan, &state); let functions = host.functions(); - for required in ["images.logo", "browser.open", "files.list", "files.uploadBegin", "files.uploadChunk", "files.uploadCommit", "files.uploadAbort", "files.download"] { + for required in ["images.logo", "browser.open", "containers.terminal", "containers.terminalDispose", "files.list", "files.uploadBegin", "files.uploadChunk", "files.uploadCommit", "files.uploadAbort", "files.download"] { assert!(functions.iter().any(|function| function == required), "missing {required}"); } // Verify real Core permission/capability routing without launching a @@ -59,6 +59,22 @@ fn packaged_backend_manages_a_real_container_without_writing_the_package() { } } let guard = Cleanup { host: &mut host, store: &mut store, id: id.clone() }; + // This Core fixture has no native window/PTY service. Validate the real + // backend's API-24 request and return that actual limitation, rather than + // inventing a successful terminal. Native interaction is host acceptance. + let terminal = guard.host.call(guard.store, "containers.terminal", json!({ + "id": id, "shell": "/bin/sh", "title": "Docker · e2e" + })); + assert!(terminal["error"].to_string().contains("plugin.host.unavailable"), "{terminal}"); + assert_eq!(guard.host.terminal_requests, 1); + let launch = &guard.host.events.iter().find(|event| event["method"] == "lithe/host/terminal/create").unwrap()["result"]["params"]; + assert_eq!(launch["arguments"][1], format!("unix://{}", socket.as_str().unwrap())); + assert_eq!(launch["arguments"][5], id); + let invalid_shell = guard.host.call(guard.store, "containers.terminal", json!({ + "id": id, "shell": "sh -c echo unsafe", "title": "Docker · e2e" + })); + assert!(invalid_shell["error"].to_string().contains("docker.invalidParams"), "{invalid_shell}"); + assert_eq!(guard.host.terminal_requests, 1, "invalid shells must not reach the host"); let inspected = guard.host.value(guard.store, "objects.inspect", json!({ "kind": "container", "id": id })); assert_eq!(inspected["State"]["Running"], true); let logs = guard.host.value(guard.store, "containers.logs", json!({ "id": id, "tail": 100 })); @@ -90,6 +106,11 @@ fn packaged_backend_manages_a_real_container_without_writing_the_package() { for action in ["pause", "unpause", "restart", "stop", "start", "stop"] { guard.host.value(guard.store, "containers.action", json!({ "id": id, "action": action })); } + let stopped_terminal = guard.host.call(guard.store, "containers.terminal", json!({ + "id": id, "shell": "/bin/sh", "title": "Docker · e2e" + })); + assert!(stopped_terminal["error"].to_string().contains("docker.terminalStopped"), "{stopped_terminal}"); + assert_eq!(guard.host.terminal_requests, 1, "stopped containers must not launch a terminal"); drop(guard); host.stop(); drop(host); @@ -174,6 +195,7 @@ struct Host { events: Vec, answered: BTreeSet, operations: usize, + terminal_requests: usize, } impl Host { @@ -203,6 +225,7 @@ impl Host { events: Vec::new(), answered: BTreeSet::new(), operations: 0, + terminal_requests: 0, }; host.await_event("the backend to become ready", |event| { event["type"] == "stateChanged" && event["state"] == "ready" @@ -270,6 +293,27 @@ impl Host { for request in pending { let id = request["result"]["hostRequestID"].clone(); let params = &request["result"]["params"]; + if request["method"] == "lithe/host/terminal/create" { + self.terminal_requests += 1; + assert_eq!(params["context"], json!({ "contextID": "docker-e2e", "generation": 1 })); + assert_eq!(params["executable"], "docker"); + let arguments = params["arguments"].as_array().expect("argv"); + assert_eq!(arguments.len(), 7); + assert_eq!(arguments[0], "--host"); + assert!(arguments[1].as_str().unwrap().starts_with("unix:///")); + assert_eq!(&arguments[2..5], &[json!("exec"), json!("--interactive"), json!("--tty")]); + assert_eq!(arguments[5].as_str().unwrap().len(), 64); + assert_eq!(arguments[6], "/bin/sh"); + assert_eq!(params["title"], "Docker · e2e"); + assert_eq!(params["retainedContextBytes"], 16384); + assert_eq!(params["environment"]["DOCKER_CONTEXT"], Value::Null); + core("lsp.hostResponse", json!({ + "sessionId": self.session, "hostRequestID": id, + "error": { "code": "plugin.host.unavailable", "message": "The Core-only fixture has no native terminal window." } + })); + self.answered.insert(id.to_string()); + continue; + } let key = params["key"].as_str().unwrap_or_default().to_string(); let result = match request["method"].as_str() { Some("lithe/host/browser/openExternal") => { diff --git a/e2e/lithe-core.json b/e2e/lithe-core.json index 2c66759..591c14f 100644 --- a/e2e/lithe-core.json +++ b/e2e/lithe-core.json @@ -1,4 +1,4 @@ { "repository": "https://github.com/1lck/Lithe-IDEA", - "revision": "04b1fb848f8f593d51e998b0cc0f487bc8c56f40" + "revision": "8b1d666933cc4250c04770fcd755073bfd07c447" } diff --git a/plugin.json b/plugin.json index ce5bac9..c39cc44 100644 --- a/plugin.json +++ b/plugin.json @@ -18,7 +18,8 @@ "backend.launch", "ui.contribute", "ui.web", - "browser.open" + "browser.open", + "terminal.create" ], "activationEvents": [ "onView:io.github.lithe-idea.docker.explorer" diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 8a1b53a..b73fd31 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -200,5 +200,18 @@ "replace": "Replace", "sampleInterval": "Sample interval", "seconds": "{count} s", - "statsStopped": "Start this container to view live statistics." + "statsStopped": "Start this container to view live statistics.", + "openTerminal": "Open terminal", + "containerShell": "Shell", + "containerTerminalTitle": "Docker · {name}", + "terminalHelp": "Open Lithe's native terminal on this engine. Show the existing session, or reopen a closed session with up to 16 KiB of display history. The Docker CLI must be on the terminal's PATH and the selected shell must exist in the container.", + "disposeTerminal": "Close and forget this container shell's terminal and history", + "docker.terminalUnavailable": "This Lithe version does not provide API-24 native terminals. Update Lithe and open the plugin in a local workspace.", + "docker.terminalStopped": "Start or resume this container before opening a terminal.", + "docker.terminalInvalid": "Could not prepare the native terminal. Refresh the container and try again.", + "plugin.host.busy": "A terminal operation is in progress. Finish or cancel it, then retry.", + "plugin.host.limitExceeded": "The plugin's eight terminal slots are in use. Close and forget an unused container terminal, then retry.", + "plugin.host.unavailable": "This host does not provide the required native terminal service.", + "plugin.host.authorityUnsupported": "Container terminals currently require a local macOS workspace.", + "plugin.host.permissionDenied": "Allow the plugin's native terminal permission, then retry." } diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index d635c8e..dc7fc5a 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -200,5 +200,18 @@ "replace": "替换", "sampleInterval": "采样间隔", "seconds": "{count} 秒", - "statsStopped": "启动此容器后查看实时统计。" + "statsStopped": "启动此容器后查看实时统计。", + "openTerminal": "打开终端", + "containerShell": "Shell", + "containerTerminalTitle": "Docker · {name}", + "terminalHelp": "在当前引擎上打开 Lithe 原生终端。已有会话会直接显示,关闭后的会话会重新启动并恢复最多 16 KiB 显示历史。终端 PATH 中需要有 Docker CLI,容器内需要有所选 Shell。", + "disposeTerminal": "关闭并释放此容器 Shell 的终端和历史", + "docker.terminalUnavailable": "当前 Lithe 版本不提供 API-24 原生终端。请更新 Lithe,并在本机工作区中打开插件。", + "docker.terminalStopped": "请先启动或恢复此容器,再打开终端。", + "docker.terminalInvalid": "无法准备原生终端。请刷新容器后重试。", + "plugin.host.busy": "终端操作正在进行。请先完成或取消,再重试。", + "plugin.host.limitExceeded": "插件的八个终端名额已用完。请关闭并释放不再使用的容器终端,再重试。", + "plugin.host.unavailable": "当前宿主不提供所需的原生终端服务。", + "plugin.host.authorityUnsupported": "容器终端目前需要本机 macOS 工作区。", + "plugin.host.permissionDenied": "请授予插件原生终端权限后重试。" } diff --git a/ui/src/ContainerTerminal.tsx b/ui/src/ContainerTerminal.tsx new file mode 100644 index 0000000..ecf29b0 --- /dev/null +++ b/ui/src/ContainerTerminal.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import { cancelled, invoke } from './bridge.ts'; +import { errorMessage, t } from './i18n.ts'; +import { Icon } from './icons.tsx'; + +export function ContainerTerminal({ id, name, running }: { id: string; name: string; running: boolean }) { + const [shell, setShell] = useState('/bin/sh'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const pending = useRef(undefined); + useEffect(() => () => pending.current?.abort(), []); + async function perform(dispose = false) { + if (pending.current) return; + const controller = new AbortController(); pending.current = controller; + setBusy(true); setError(''); + try { + await invoke(dispose ? 'containers.terminalDispose' : 'containers.terminal', { + id, shell, title: t('containerTerminalTitle', { name: [...name].slice(0, 24).join('') }), + }, controller.signal).result; + } catch (failure) { + if (!controller.signal.aborted && !cancelled(failure)) setError(errorMessage(failure)); + } finally { + pending.current = undefined; + setBusy(false); + } + } + return <>
+ + {busy ? + : } +
+ {error && } + ; +} diff --git a/ui/src/details.tsx b/ui/src/details.tsx index 2ac342c..e39fab7 100644 --- a/ui/src/details.tsx +++ b/ui/src/details.tsx @@ -7,6 +7,7 @@ import { TextViewport } from './TextViewport.tsx'; import { Icon } from './icons.tsx'; import { ContainerFiles } from './ContainerFiles.tsx'; import { ResourceSummary } from './ResourceSummary.tsx'; +import { ContainerTerminal } from './ContainerTerminal.tsx'; export function TextView({ value, label }: { value: string; label: string }) { const [copied, setCopied] = useState(false); @@ -169,19 +170,21 @@ function RemoteText({ fn, params, label }: { fn: string; params: object; label: : text === null ?
{t('loading')}
: ; } -export function ResourceDetails({ item, section, revision, sampleInterval, onSampleInterval }: { item: Resource; section: Section; revision: number; sampleInterval: number; onSampleInterval(value: number): void }) { +export function ResourceDetails({ item, section, revision, sampleInterval, onSampleInterval, active }: { item: Resource; section: Section; revision: number; sampleInterval: number; onSampleInterval(value: number): void; active: boolean }) { const [tab, setTab] = useState('details'); const container = 'state' in item ? item : null; const kind = section === 'images' ? 'image' : section === 'volumes' ? 'volume' : section === 'networks' ? 'network' : 'container'; const id = 'id' in item ? item.id : item.name; const tabs = container ? ['details', 'logs', 'stats', 'files', 'processes', 'inspect'] as const : ['details', 'inspect'] as const; - return
{tabs.map(value => )}
- {tab === 'details' && } + {container && } +
+ {active && <>{tab === 'details' && } {tab === 'logs' && container && } {tab === 'stats' && container && } {tab === 'files' && container && } {tab === 'processes' && } - {tab === 'inspect' && } + {tab === 'inspect' && }} ; } diff --git a/ui/src/icons.tsx b/ui/src/icons.tsx index d8d80cb..f5dab3a 100644 --- a/ui/src/icons.tsx +++ b/ui/src/icons.tsx @@ -1,6 +1,6 @@ import { Box, Boxes, Layers, HardDrive, Network, ChartPie, Settings2, Search, RefreshCw, Plus, Download, Play, Square, RotateCw, Pause, Trash2, ChevronRight, - ChevronDown, CircleX, X, PackageOpen, Cable, WrapText, ArrowUp, Upload, Folder, File } from 'lucide-preact'; + ChevronDown, CircleX, X, PackageOpen, Cable, WrapText, ArrowUp, Upload, Folder, File, Terminal } from 'lucide-preact'; const icons = { containers: Box, images: Layers, volumes: HardDrive, networks: Network, @@ -9,6 +9,7 @@ const icons = { pause: Pause, unpause: Play, kill: CircleX, remove: Trash2, collapsed: ChevronRight, expanded: ChevronDown, close: X, project: Boxes, empty: PackageOpen, endpoint: Cable, wrap: WrapText, up: ArrowUp, upload: Upload, folder: Folder, file: File, + terminal: Terminal, }; export type IconName = keyof typeof icons; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 7cc8eb1..68adb1d 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -180,7 +180,7 @@ function App() { : 'tags' in item ? : null} {(!('builtin' in item) || !item.builtin) && } - {visible && } + }} diff --git a/ui/src/style.css b/ui/src/style.css index 174518e..d39b267 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -56,6 +56,9 @@ h1, h2 { font-size: 13px; margin: 0; font-weight: 600; } .outlined { border-color: var(--border); } .muted { color: var(--secondary); } .filterbar, .subtoolbar { flex-shrink: 0; display: flex; align-items: center; gap: 14px; padding: 4px 12px; border-bottom: 1px solid var(--border); min-height: 34px; font-size: 12px; } +.container-terminal { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 6px; } +.container-terminal label { display: inline-flex; align-items: center; gap: 6px; } +.terminal-error { grid-column: 1 / -1; } .search-field { display: flex; align-items: center; gap: 6px; border: 1px solid var(--border); background: var(--surface); border-radius: 5px; padding-left: 8px; flex: 1; max-width: 340px; color: var(--secondary); min-width: 100px; } .search-field:focus-within { outline: 2px solid var(--accent); outline-offset: -1px; } .search-field input[type='search'] { border: 0; outline: 0; padding: 4px 7px 4px 0; background: transparent; width: 100%; color: var(--foreground); font-size: 12px; } @@ -108,10 +111,13 @@ h1, h2 { font-size: 13px; margin: 0; font-weight: 600; } .detail-heading>strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } .detail-actions { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; justify-content: flex-end; } .detail-actions button { font-size: 12px; padding: 3px 6px; } -.detail-body { flex: 1; min-height: 0; display: flex; flex-direction: column; } -.tabs { display: flex; overflow-x: auto; flex-shrink: 0; align-self: center; max-width: calc(100% - 24px); margin: 8px 12px; padding: 3px; gap: 2px; border: 1px solid var(--border); border-radius: 18px; background: var(--surface); } +.detail-body { flex: 1; min-height: 0; display: flex; flex-direction: column; container-type: inline-size; container-name: resource-details; } +.detail-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 8px 12px; flex-shrink: 0; } +.tabs { grid-column: 2; justify-self: center; display: flex; overflow-x: auto; min-width: 0; max-width: 100%; padding: 3px; gap: 2px; border: 1px solid var(--border); border-radius: 18px; background: var(--surface); } .tabs button { border: 0; border-radius: 14px; min-height: 27px; padding: 4px 12px; font-size: 12px; color: var(--secondary); } .tabs [aria-selected='true'] { background: color-mix(in srgb, var(--foreground) 10%, var(--surface)); color: var(--foreground); font-weight: 500; } +@container resource-details (max-width: 900px) { .detail-toolbar { grid-template-columns: minmax(0, 1fr) auto; } .tabs { grid-column: 1; } .container-terminal { grid-column: 2; } } +@container resource-details (max-width: 500px) { .detail-toolbar { grid-template-columns: minmax(0, 1fr); } .tabs, .container-terminal { grid-column: 1; } .container-terminal { flex-wrap: wrap; justify-content: flex-end; } } .empty { color: var(--secondary); text-align: center; padding: 35px 20px; margin: auto; } .disconnected { display: flex; flex-direction: column; align-items: center; max-width: 500px; gap: 10px; } .disconnected img { width: 60px; height: 60px; margin-bottom: 8px; } @@ -265,6 +271,6 @@ progress { accent-color: var(--accent); flex: 1; } @media (max-width: 1000px) { .sidebar { width: clamp(48px, var(--pane-width, 48px), max(48px, min(360px, calc(100vw - 280px)))); } .resource-grid { padding-inline: 12px; } .toolbar-filters .resource-total { display: none; } } @media (max-width: 1050px) { .connection { grid-template-columns: minmax(200px, .8fr) minmax(280px, 1fr); gap: 20px; padding: 14px; } .connection-config { padding-right: 20px; } .connection-info dl { grid-template-columns: 1fr; } } @media (max-height: 440px) { .sidebar { padding-top: 7px; gap: 2px; } .brand { padding-bottom: 8px; } .brand img { width: 26px; height: 26px; } .sidebar button { height: 29px; min-height: 27px; } .sidebar .engine-status { margin-top: 5px; padding-top: 5px; height: 30px; } .connection { padding-block: 12px; } .connection-heading { margin-bottom: 10px; } .sparkline svg { height: 54px; } } -@media (max-height: 440px) { .tabs { margin-block: 5px; } .info-card>div { padding: 8px 0; } .detail-section { margin-block: 14px; } } +@media (max-height: 440px) { .detail-toolbar { padding-block: 5px; } .info-card>div { padding: 8px 0; } .detail-section { margin-block: 14px; } } @media (max-width: 700px) { .resource-layout.has-selection { flex-direction: column; } .resource-list { flex: 1; min-height: 100px; } .detail-panel { flex: 0 0 auto; height: clamp(100px, var(--pane-height, 60%), max(100px, calc(100% - 100px))); width: 100%; min-width: 0; min-height: 100px; border-left: 0; border-top: 1px solid var(--border); } .resize-details { top: -5px; bottom: auto; left: 0; right: 0; height: 10px; width: auto; cursor: ns-resize; } .filterbar { gap: 8px; padding: 7px 10px; } .toolbar { gap: 8px; padding-inline: 10px; } .resource-total { display: none; } .sparks { gap: 8px; } } @media (max-width: 700px) { .connection { display: block; } .connection-config { padding: 0 0 18px; margin-bottom: 16px; border: 0; border-bottom: 1px solid var(--border); } .toolbar { flex-wrap: wrap; gap: 6px; } .toolbar-filters { order: 2; flex-basis: 100%; margin-left: 0; } .log-ended { display: none; } } From 1fa15f6f94615c8645b725c6e2577145fefc6b7c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 22:34:45 +0800 Subject: [PATCH 2/3] fix(ci): run Colima integration on hosted Intel macOS --- .github/workflows/ci.yml | 11 ++++++++++- README.md | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54090c1..da47722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,9 @@ jobs: package: name: Package and end-to-end check needs: [ui, backend] - runs-on: macos-26 + # Hosted ARM Macs cannot start Colima's nested VZ VM. Colima's own macOS + # integration lane uses Intel; keep both universal slices in this build. + runs-on: macos-15-intel timeout-minutes: 45 steps: - uses: actions/checkout@v7 @@ -87,10 +89,17 @@ jobs: npm ci --prefix ui node scripts/package.mjs - name: Start a local Docker engine + id: docker-engine + timeout-minutes: 10 run: | brew install colima docker colima start --cpu 2 --memory 2 --disk 10 docker pull alpine:3.21 + - name: Diagnose Docker engine startup + if: failure() && steps.docker-engine.outcome == 'failure' + timeout-minutes: 1 + run: | + find "$HOME/.colima" -type f \( -name 'ha.stderr.log' -o -name 'serial*.log' -o -name 'qemu.stderr.log' \) -print -exec tail -n 150 {} \; - name: Run the backend through Rust Core and Docker run: node scripts/e2e.mjs - name: Stop the test engine diff --git a/README.md b/README.md index f081a56..85ef8fc 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,10 @@ docker pull alpine:3.21 node scripts/e2e.mjs ``` +CI 的打包与真实 Docker 集成车道使用 `macos-15-intel`,与 Colima 上游的 macOS +集成车道一致,避免托管 ARM Mac 无法启动嵌套 VZ 虚拟机的问题;仍构建和校验 +arm64 / x86_64 两个 universal slice。引擎启动限时 10 分钟,失败时输出 Lima 启动日志。 + 该场景读取现有资源,创建专用临时容器,验证日志、统计、进程、暂停、恢复、 重启、停止、启动、删除、中文文件名分块上传/下载、原生选中文件上传对应的后端路径、 取消传输清理、浏览器与 API-24 终端请求的 Core 权限/上下文路由、停止容器拒绝终端、连接持久化及包文件不变性。 From 8cd74286da12646591d5fe2c55dbc1bedcfc1ad8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 22:37:30 +0800 Subject: [PATCH 3/3] fix(release): align Docker integration with Intel CI runner --- .github/workflows/release.yml | 11 ++++++++++- README.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 238dc6e..610af34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,9 @@ concurrency: jobs: release: name: Release ${{ github.ref_name }} - runs-on: macos-26 + # Match CI and Colima's macOS integration lane: hosted ARM Macs cannot + # start the nested VZ VM required by the real Docker check. + runs-on: macos-15-intel timeout-minutes: 60 permissions: contents: write @@ -45,10 +47,17 @@ jobs: - name: Build the package run: node scripts/package.mjs - name: Start a local Docker engine + id: docker-engine + timeout-minutes: 10 run: | brew install colima docker colima start --cpu 2 --memory 2 --disk 10 docker pull alpine:3.21 + - name: Diagnose Docker engine startup + if: failure() && steps.docker-engine.outcome == 'failure' + timeout-minutes: 1 + run: | + find "$HOME/.colima" -type f \( -name 'ha.stderr.log' -o -name 'serial*.log' -o -name 'qemu.stderr.log' \) -print -exec tail -n 150 {} \; - name: Run the backend through Rust Core and Docker run: node scripts/e2e.mjs - name: Stop the test engine diff --git a/README.md b/README.md index 85ef8fc..7371293 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ docker pull alpine:3.21 node scripts/e2e.mjs ``` -CI 的打包与真实 Docker 集成车道使用 `macos-15-intel`,与 Colima 上游的 macOS +CI 与发布流程的打包和真实 Docker 集成车道使用 `macos-15-intel`,与 Colima 上游的 macOS 集成车道一致,避免托管 ARM Mac 无法启动嵌套 VZ 虚拟机的问题;仍构建和校验 arm64 / x86_64 两个 universal slice。引擎启动限时 10 分钟,失败时输出 Lima 启动日志。