从左侧选择一个终端
画面和输入会在浏览器与这台 Mac 上的 Glint 会话之间实时同步。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Glint/Resources/WebRemote/web-remote.css b/Glint/Resources/WebRemote/web-remote.css
index 97d3f34c..ee873e63 100644
--- a/Glint/Resources/WebRemote/web-remote.css
+++ b/Glint/Resources/WebRemote/web-remote.css
@@ -293,6 +293,21 @@ input:focus {
grid-template-rows: minmax(0, 1fr) auto;
background: var(--terminal-bg);
}
+.terminal-files-button {
+ position: absolute;
+ z-index: 3;
+ top: 11px;
+ right: 12px;
+ width: 30px;
+ height: 30px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ color: var(--muted);
+ background: var(--panel-2);
+ font-size: 16px;
+}
+.terminal-files-button:hover { color: var(--accent); }
+.terminal-files-button[hidden], .terminal-panel.show-files .terminal-files-button { display: none; }
#terminal {
min-width: 0;
@@ -304,6 +319,125 @@ input:focus {
touch-action: manipulation;
}
#terminal.visible { display: block; }
+.terminal-panel.show-files #terminal,
+.terminal-panel.show-files .empty-state,
+.terminal-panel.show-files .quick-keys { display: none; }
+.files-panel {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ min-height: 0;
+ flex-direction: column;
+ background: var(--panel-2);
+}
+.files-panel[hidden] { display: none; }
+.files-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px;
+ border-bottom: 1px solid var(--line);
+}
+.files-toolbar .icon-button:disabled { opacity: .35; cursor: default; }
+.files-location {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--muted);
+ font: 11px ui-monospace, monospace;
+}
+.files-body {
+ display: grid;
+ grid-template-columns: minmax(180px, 32%) minmax(0, 1fr);
+ min-height: 0;
+ flex: 1;
+}
+.files-list {
+ min-height: 0;
+ overflow: auto;
+ padding: 8px;
+ border-right: 1px solid var(--line);
+}
+.file-entry {
+ display: block;
+ width: 100%;
+ overflow: hidden;
+ border: 0;
+ border-radius: 6px;
+ padding: 7px;
+ color: var(--text);
+ background: transparent;
+ font: 11px ui-monospace, monospace;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.file-entry:hover:not(:disabled) { background: var(--overlay); }
+.file-entry:disabled { color: var(--faint); cursor: default; }
+.files-list-note { padding: 0 7px; color: var(--faint); font-size: 10px; }
+.files-preview { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
+.files-preview-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 36px;
+ border-bottom: 1px solid var(--line);
+}
+.files-preview-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ padding: 10px 14px;
+ color: var(--muted);
+ font: 11px ui-monospace, monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.files-preview-header .ghost-button { margin-right: 10px; font-size: 10px; }
+#files-content {
+ min-height: 0;
+ overflow: auto;
+ flex: 1;
+ margin: 0;
+ padding: 14px;
+ color: var(--text);
+ font: 12px/1.5 ui-monospace, "SFMono-Regular", Menlo, monospace;
+ tab-size: 4;
+}
+#files-content[hidden], #files-image-viewport[hidden], #files-html[hidden],
+#files-source-toggle[hidden] { display: none; }
+.files-image-viewport {
+ position: relative;
+ min-height: 0;
+ flex: 1;
+ overflow: hidden;
+ touch-action: none;
+ overscroll-behavior: contain;
+ background: var(--terminal-bg);
+}
+#files-image {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ max-width: 100%;
+ max-height: 100%;
+ transform: translate(-50%, -50%) translate(var(--pan-x, 0px), var(--pan-y, 0px)) scale(var(--zoom, 1));
+ transform-origin: center;
+ user-select: none;
+ -webkit-user-drag: none;
+ pointer-events: none;
+}
+#files-html {
+ width: 100%;
+ min-height: 0;
+ flex: 1;
+ border: 0;
+ background: #fff;
+}
+.files-message { margin: 0; padding: 8px 14px; color: var(--muted); font-size: 11px; }
#terminal .xterm { height: 100%; }
#terminal .xterm-viewport { scrollbar-color: color-mix(in srgb, var(--text) 16%, transparent) transparent; }
@@ -446,6 +580,7 @@ dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(8px); }
.workspace-pane-list { overflow: visible; }
#workspace-list { white-space: normal; overflow: auto; }
.terminal-panel { height: 100%; }
+ .files-body { grid-template-columns: minmax(110px, 40%) minmax(0, 1fr); }
#terminal { padding-top: 7px; }
}
diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js
index 48ae93f8..6e7fb5a6 100644
--- a/Glint/Resources/WebRemote/web-remote.js
+++ b/Glint/Resources/WebRemote/web-remote.js
@@ -76,6 +76,25 @@ const translations = {
disconnected: "连接已断开",
enter_access_key: "输入访问密钥",
existing_directory_hint: "目录必须已经存在;不会自动 git init 或 clone。",
+ files: "文件",
+ open_current_folder: "查看当前目录文件",
+ files_up: "上一级",
+ files_refresh: "刷新文件",
+ files_close: "返回终端",
+ files_empty: "此目录没有文件",
+ files_choose: "选择文件以预览",
+ files_loading: "正在读取…",
+ html_preview: "页面预览",
+ html_source: "源码",
+ html_preview_notice: "静态预览:脚本和外部资源不会加载",
+ image_too_large: "图片过大,无法生成预览",
+ image_unavailable: "无法解码此图片",
+ files_limited: "仅显示前 200 项",
+ files_unavailable: "当前终端没有可访问的本地目录",
+ invalid_file_path: "文件路径无效或超出项目目录",
+ file_unavailable: "文件或目录无法读取",
+ file_too_large: "文件超过 128 KB,无法预览",
+ file_not_text: "仅支持预览 UTF-8 文本文件",
invalid_project_path: "目录不存在,或不是这台 Mac 上可访问的文件夹。",
loading_terminal: "正在载入终端",
last_terminal: "每个 Workspace 至少需要保留一个终端",
@@ -127,6 +146,25 @@ const translations = {
disconnected: "Disconnected",
enter_access_key: "Enter access key",
existing_directory_hint: "The directory must already exist; Glint will not run git init or clone.",
+ files: "Files",
+ open_current_folder: "Browse the current terminal folder",
+ files_up: "Parent folder",
+ files_refresh: "Refresh files",
+ files_close: "Back to terminal",
+ files_empty: "This folder is empty",
+ files_choose: "Select a file to preview",
+ files_loading: "Loading…",
+ html_preview: "Preview",
+ html_source: "Source",
+ html_preview_notice: "Static preview: scripts and external resources are disabled",
+ image_too_large: "This image is too large to preview",
+ image_unavailable: "Unable to decode this image",
+ files_limited: "Showing the first 200 items",
+ files_unavailable: "This terminal has no accessible local directory",
+ invalid_file_path: "File path is invalid or outside the workspace",
+ file_unavailable: "File or folder cannot be read",
+ file_too_large: "Files over 128 KB cannot be previewed",
+ file_not_text: "Only UTF-8 text files can be previewed",
invalid_project_path: "The directory does not exist or is not accessible on this Mac.",
loading_terminal: "Loading terminal",
last_terminal: "Each workspace must keep at least one terminal",
@@ -194,6 +232,20 @@ const elements = {
createMessage: document.querySelector("#create-message"),
createProject: document.querySelector("#create-project"),
emptyState: document.querySelector("#empty-state"),
+ filesPanel: document.querySelector("#files-panel"),
+ filesUp: document.querySelector("#files-up"),
+ filesLocation: document.querySelector("#files-location"),
+ filesRefresh: document.querySelector("#files-refresh"),
+ filesClose: document.querySelector("#files-close"),
+ filesList: document.querySelector("#files-list"),
+ filesPreviewName: document.querySelector("#files-preview-name"),
+ filesContent: document.querySelector("#files-content"),
+ filesImage: document.querySelector("#files-image"),
+ filesImageViewport: document.querySelector("#files-image-viewport"),
+ filesHTML: document.querySelector("#files-html"),
+ filesSourceToggle: document.querySelector("#files-source-toggle"),
+ filesMessage: document.querySelector("#files-message"),
+ openFiles: document.querySelector("#open-files"),
projectPath: document.querySelector("#project-path"),
reconnect: document.querySelector("#reconnect"),
refresh: document.querySelector("#refresh"),
@@ -288,6 +340,13 @@ let resizeTimer;
let lastSentTerminalSize = "";
let appliedBrandSignature = "";
let appliedThemeSignature = "";
+let fileWorkspace = "";
+let filePane = "";
+let fileRoot = "";
+let fileDirectory = "";
+let filePath = "";
+let htmlSource = "";
+let htmlSourceShown = false;
const mobileSidebarLayout = matchMedia(
"(max-width: 760px), (max-width: 900px) and (max-height: 520px) and (orientation: landscape)"
);
@@ -323,6 +382,7 @@ function connect() {
lastServerMessageAt = Date.now();
authenticated = false;
controllingPane = "";
+ updateFilesButton();
resetSession();
setStatus("connecting", t("connecting"));
socket = new WebSocket(websocketURL());
@@ -349,6 +409,7 @@ function connect() {
if (socket !== currentSocket) return;
authenticated = false;
controllingPane = "";
+ updateFilesButton();
setStatus("error", t("disconnected"));
reconnectTimer = setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 1.8, 8000);
@@ -468,6 +529,7 @@ function handleMessage(raw) {
break;
case "authenticated":
authenticated = true;
+ updateFilesButton();
setStatus("connected", t("connected"));
if (elements.authDialog.open) elements.authDialog.close();
elements.authError.textContent = "";
@@ -478,6 +540,55 @@ function handleMessage(raw) {
applyTheme(message.theme);
renderState(message);
chooseInitialPane(message);
+ updateFilesButton();
+ if (filePane && !message.workspaces.some(item =>
+ item.id === fileWorkspace && item.panes?.some(pane => pane.id === filePane))) {
+ closeFiles();
+ }
+ break;
+ case "fileList":
+ if (message.workspace === fileWorkspace && message.pane === filePane && message.path === fileDirectory) {
+ fileRoot = message.root || "";
+ updateFileLocation();
+ renderFiles(message.entries || [], message.limit);
+ }
+ break;
+ case "fileContent":
+ if (message.workspace === fileWorkspace && message.pane === filePane && message.path === filePath) {
+ if (message.format === "html") {
+ htmlSource = message.content;
+ htmlSourceShown = false;
+ elements.filesHTML.srcdoc = sandboxedHTML(message.content);
+ elements.filesHTML.hidden = false;
+ elements.filesContent.hidden = true;
+ elements.filesSourceToggle.hidden = false;
+ elements.filesSourceToggle.textContent = t("html_source");
+ elements.filesMessage.textContent = t("html_preview_notice");
+ } else {
+ elements.filesContent.textContent = message.content;
+ elements.filesMessage.textContent = "";
+ }
+ }
+ break;
+ case "fileImage":
+ if (message.workspace === fileWorkspace && message.pane === filePane && message.path === filePath) {
+ elements.filesImage.src = `data:image/png;base64,${message.data}`;
+ elements.filesImage.alt = elements.filesPreviewName.textContent;
+ imageZoom.reset();
+ elements.filesImageViewport.hidden = false;
+ elements.filesContent.hidden = true;
+ elements.filesMessage.textContent = "";
+ }
+ break;
+ case "fileError":
+ if (message.workspace !== fileWorkspace || message.pane !== filePane) break;
+ if (message.path === filePath && filePath) {
+ elements.filesContent.textContent = "";
+ elements.filesMessage.textContent = fileErrorLabel(message.code);
+ } else if (message.path === fileDirectory) {
+ elements.filesList.replaceChildren();
+ elements.filesMessage.textContent = fileErrorLabel(message.code);
+ }
break;
case "snapshot":
if (message.pane !== selectedPane) return;
@@ -500,6 +611,7 @@ function handleMessage(raw) {
elements.createDialog.close();
selectedPane = "";
controllingPane = "";
+ updateFilesButton();
sessionStorage.removeItem("glint-selected-pane");
setSidebarOpen(false);
setStatus("connected", t("project_opened"));
@@ -520,6 +632,7 @@ function handleMessage(raw) {
}
case "terminalClosed":
if (message.pane === selectedPane) {
+ if (filePane === selectedPane) closeFiles();
selectedPane = "";
controllingPane = "";
clearTimeout(paneRetryTimer);
@@ -529,6 +642,7 @@ function handleMessage(raw) {
elements.terminal.classList.remove("visible");
elements.emptyState.classList.remove("hidden");
elements.activeLabel.textContent = t("select_terminal");
+ updateFilesButton();
}
setStatus("connected", t("terminal_closed"));
break;
@@ -618,6 +732,7 @@ function applyTheme(theme) {
function handleError(code) {
if (code === "unauthorized") {
authenticated = false;
+ updateFilesButton();
token = "";
encrypted = false; // proof failed; we're still in the handshake
sessionStorage.removeItem("glint-session-token");
@@ -656,6 +771,240 @@ function errorLabel(code) {
return labels[code] || t("operation_failed", { code });
}
+function fileErrorLabel(code) {
+ return t({
+ "files-unavailable": "files_unavailable",
+ "invalid-file-path": "invalid_file_path",
+ "file-unavailable": "file_unavailable",
+ "file-too-large": "file_too_large",
+ "file-not-text": "file_not_text",
+ "image-too-large": "image_too_large",
+ "image-unavailable": "image_unavailable",
+ }[code] || "file_unavailable");
+}
+
+function sandboxedHTML(html) {
+ const policy = ``;
+ const doctype = /^\s*]*>/i.exec(html);
+ return doctype
+ ? `${doctype[0]}${policy}${html.slice(doctype[0].length)}`
+ : `${policy}${html}`;
+}
+
+function installImageZoom(viewport, image) {
+ let scale = 1;
+ let offsetX = 0;
+ let offsetY = 0;
+ let gesture = null;
+ let safariGestureScale = 1;
+ const maxScale = 6;
+ const clamp = (value, lower, upper) => Math.min(upper, Math.max(lower, value));
+ const center = touches => ({
+ x: (touches[0].clientX + touches[1].clientX) / 2,
+ y: (touches[0].clientY + touches[1].clientY) / 2,
+ });
+ const distance = touches => Math.hypot(
+ touches[0].clientX - touches[1].clientX,
+ touches[0].clientY - touches[1].clientY
+ );
+
+ function apply() {
+ const maxX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2);
+ const maxY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2);
+ offsetX = clamp(offsetX, -maxX, maxX);
+ offsetY = clamp(offsetY, -maxY, maxY);
+ image.style.setProperty("--zoom", scale);
+ image.style.setProperty("--pan-x", `${offsetX}px`);
+ image.style.setProperty("--pan-y", `${offsetY}px`);
+ }
+
+ function pointFromClient(clientX, clientY) {
+ const rect = viewport.getBoundingClientRect();
+ return { x: clientX - rect.left - rect.width / 2,
+ y: clientY - rect.top - rect.height / 2 };
+ }
+
+ function zoomAt(clientX, clientY, nextScale) {
+ const point = pointFromClient(clientX, clientY);
+ const ratio = nextScale / scale;
+ offsetX = point.x - (point.x - offsetX) * ratio;
+ offsetY = point.y - (point.y - offsetY) * ratio;
+ scale = nextScale;
+ apply();
+ }
+
+ function startGesture(touches) {
+ if (touches.length >= 2) {
+ const middle = center(touches);
+ const point = pointFromClient(middle.x, middle.y);
+ gesture = {
+ kind: "pinch", distance: Math.max(1, distance(touches)), startScale: scale,
+ imageX: (point.x - offsetX) / scale,
+ imageY: (point.y - offsetY) / scale,
+ };
+ } else if (touches.length === 1 && scale > 1) {
+ gesture = {
+ kind: "pan", x: touches[0].clientX, y: touches[0].clientY,
+ offsetX, offsetY,
+ };
+ } else {
+ gesture = null;
+ }
+ }
+
+ viewport.addEventListener("touchstart", event => {
+ startGesture(event.touches);
+ if (gesture) event.preventDefault();
+ }, { passive: false });
+ viewport.addEventListener("touchmove", event => {
+ if (event.touches.length >= 2) {
+ if (gesture?.kind !== "pinch") startGesture(event.touches);
+ const middle = center(event.touches);
+ const point = pointFromClient(middle.x, middle.y);
+ scale = clamp(gesture.startScale * distance(event.touches) / gesture.distance, 1, maxScale);
+ offsetX = point.x - gesture.imageX * scale;
+ offsetY = point.y - gesture.imageY * scale;
+ apply();
+ event.preventDefault();
+ } else if (event.touches.length === 1 && gesture?.kind === "pan") {
+ offsetX = gesture.offsetX + event.touches[0].clientX - gesture.x;
+ offsetY = gesture.offsetY + event.touches[0].clientY - gesture.y;
+ apply();
+ event.preventDefault();
+ }
+ }, { passive: false });
+ const finishTouch = event => startGesture(event.touches);
+ viewport.addEventListener("touchend", finishTouch);
+ viewport.addEventListener("touchcancel", finishTouch);
+ viewport.addEventListener("dblclick", () => reset());
+ viewport.addEventListener("wheel", event => {
+ if (!event.ctrlKey) return;
+ event.preventDefault();
+ zoomAt(event.clientX, event.clientY,
+ clamp(scale * Math.exp(-event.deltaY * 0.01), 1, maxScale));
+ }, { passive: false });
+ viewport.addEventListener("gesturestart", event => {
+ event.preventDefault();
+ safariGestureScale = scale;
+ }, { passive: false });
+ viewport.addEventListener("gesturechange", event => {
+ event.preventDefault();
+ if (gesture?.kind === "pinch") return;
+ const rect = viewport.getBoundingClientRect();
+ zoomAt(event.clientX || rect.left + rect.width / 2,
+ event.clientY || rect.top + rect.height / 2,
+ clamp(safariGestureScale * event.scale, 1, maxScale));
+ }, { passive: false });
+ window.addEventListener("resize", apply);
+
+ function reset() {
+ scale = 1;
+ offsetX = 0;
+ offsetY = 0;
+ gesture = null;
+ apply();
+ }
+ return { reset };
+}
+
+const imageZoom = installImageZoom(elements.filesImageViewport, elements.filesImage);
+
+function clearFilePreview() {
+ htmlSource = "";
+ htmlSourceShown = false;
+ elements.filesContent.textContent = "";
+ elements.filesContent.hidden = false;
+ elements.filesImageViewport.hidden = true;
+ elements.filesImage.removeAttribute("src");
+ imageZoom.reset();
+ elements.filesHTML.hidden = true;
+ elements.filesHTML.srcdoc = "";
+ elements.filesSourceToggle.hidden = true;
+}
+
+function updateFilesButton() {
+ elements.openFiles.hidden = !authenticated || !selectedPane;
+}
+
+function updateFileLocation() {
+ elements.filesLocation.textContent = fileRoot
+ ? `${fileRoot}${fileDirectory ? `/${fileDirectory}` : ""}`
+ : (fileDirectory ? `/${fileDirectory}` : "/");
+}
+
+function openFiles() {
+ if (!authenticated || !selectedPane) return;
+ const workspace = lastState?.workspaces.find(item =>
+ item.panes?.some(pane => pane.id === selectedPane));
+ if (!workspace) return;
+ fileWorkspace = workspace.id;
+ filePane = selectedPane;
+ fileRoot = workspace.panes.find(pane => pane.id === selectedPane)?.cwd || "";
+ fileDirectory = "";
+ filePath = "";
+ elements.filesPanel.hidden = false;
+ document.querySelector(".terminal-panel").classList.add("show-files");
+ elements.filesPreviewName.textContent = "";
+ clearFilePreview();
+ elements.filesMessage.textContent = t("files_choose");
+ browseFiles("");
+ if (mobileSidebarLayout.matches) setSidebarOpen(false);
+}
+
+function closeFiles() {
+ fileWorkspace = "";
+ filePane = "";
+ fileRoot = "";
+ filePath = "";
+ clearFilePreview();
+ elements.filesPanel.hidden = true;
+ document.querySelector(".terminal-panel").classList.remove("show-files");
+ fitTerminal();
+}
+
+function browseFiles(path) {
+ fileDirectory = path;
+ filePath = "";
+ updateFileLocation();
+ elements.filesUp.disabled = !path;
+ elements.filesList.replaceChildren();
+ elements.filesPreviewName.textContent = "";
+ clearFilePreview();
+ elements.filesMessage.textContent = t("files_loading");
+ send({ type: "listFiles", workspace: fileWorkspace, pane: filePane, path });
+}
+
+function renderFiles(entries, limit) {
+ elements.filesList.replaceChildren();
+ for (const entry of entries) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "file-entry";
+ button.disabled = entry.kind === "link";
+ button.textContent = `${entry.kind === "directory" ? "▸" : entry.kind === "link" ? "↗" : "▤"} ${entry.name}`;
+ button.addEventListener("click", () => {
+ const path = fileDirectory ? `${fileDirectory}/${entry.name}` : entry.name;
+ if (entry.kind === "directory") {
+ browseFiles(path);
+ } else {
+ filePath = path;
+ elements.filesPreviewName.textContent = entry.name;
+ clearFilePreview();
+ elements.filesMessage.textContent = t("files_loading");
+ send({ type: "readFile", workspace: fileWorkspace, pane: filePane, path });
+ }
+ });
+ elements.filesList.append(button);
+ }
+ elements.filesMessage.textContent = entries.length ? t("files_choose") : t("files_empty");
+ if (entries.length >= limit) {
+ const note = document.createElement("p");
+ note.className = "files-list-note";
+ note.textContent = t("files_limited");
+ elements.filesList.append(note);
+ }
+}
+
function chooseInitialPane(state) {
const panes = state.workspaces.flatMap(workspace => workspace.panes || []);
const remembered = panes.find(pane => pane.id === selectedPane);
@@ -754,7 +1103,9 @@ function renderState(state) {
}
function selectPane(pane) {
+ if (filePane) closeFiles();
selectedPane = pane;
+ updateFilesButton();
controllingPane = "";
clearTimeout(paneRetryTimer);
paneRetryCount = 0;
@@ -828,6 +1179,23 @@ elements.authForm.addEventListener("submit", event => {
elements.reconnect.addEventListener("click", connect);
elements.refresh.addEventListener("click", () => send({ type: "list" }));
+elements.openFiles.addEventListener("click", openFiles);
+elements.filesUp.addEventListener("click", () => {
+ browseFiles(fileDirectory.split("/").slice(0, -1).join("/"));
+});
+elements.filesRefresh.addEventListener("click", () => browseFiles(fileDirectory));
+elements.filesClose.addEventListener("click", closeFiles);
+elements.filesSourceToggle.addEventListener("click", () => {
+ htmlSourceShown = !htmlSourceShown;
+ elements.filesContent.textContent = htmlSource;
+ elements.filesContent.hidden = !htmlSourceShown;
+ elements.filesHTML.hidden = htmlSourceShown;
+ elements.filesSourceToggle.textContent = t(htmlSourceShown ? "html_preview" : "html_source");
+ elements.filesMessage.textContent = htmlSourceShown ? "" : t("html_preview_notice");
+});
+elements.filesHTML.title = t("html_preview");
+[ ["filesUp", "files_up"], ["filesRefresh", "files_refresh"], ["filesClose", "files_close"] ]
+ .forEach(([element, label]) => elements[element].setAttribute("aria-label", t(label)));
elements.autoFocusTerminal.addEventListener("change", () => {
autoFocusTerminal = elements.autoFocusTerminal.checked;
localStorage.setItem(autoFocusStorageKey, String(autoFocusTerminal));
diff --git a/Glint/WebRemote/WebRemoteProtocol.swift b/Glint/WebRemote/WebRemoteProtocol.swift
index 3431b59f..772f1e72 100644
--- a/Glint/WebRemote/WebRemoteProtocol.swift
+++ b/Glint/WebRemote/WebRemoteProtocol.swift
@@ -1,6 +1,7 @@
import CryptoKit
import Darwin
import Foundation
+import ImageIO
import Security
import os
@@ -157,6 +158,133 @@ enum WebRemoteProjectPath {
}
}
+/// Read-only, workspace-scoped file access for the browser. Paths on the wire
+/// are relative to the workspace root; links are never followed.
+enum WebRemoteFiles {
+ static let maxPreviewBytes = 128 * 1024
+ static let maxImageBytes = 40 * 1024 * 1024
+ static let maxImagePreviewBytes = 5 * 1024 * 1024
+ static let maxEntries = 200
+ private static let imageExtensions: Set = [
+ "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "ico", "avif"
+ ]
+
+ static func isImage(_ path: String) -> Bool {
+ imageExtensions.contains((path as NSString).pathExtension.lowercased())
+ }
+
+ static func isHTML(_ path: String) -> Bool {
+ ["html", "htm", "xhtml"].contains((path as NSString).pathExtension.lowercased())
+ }
+
+ static func resolve(root: String, relativePath: String) -> URL? {
+ guard relativePath.utf8.count <= 4096 else { return nil }
+ let components = relativePath.isEmpty ? [] : relativePath.split(separator: "/", omittingEmptySubsequences: false)
+ guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("\0") }) else {
+ return nil
+ }
+ let rootURL = URL(fileURLWithPath: root, isDirectory: true).resolvingSymlinksInPath()
+ var target = rootURL
+ for component in components {
+ target.appendPathComponent(String(component))
+ guard let values = try? target.resourceValues(forKeys: [.isSymbolicLinkKey]),
+ values.isSymbolicLink != true else { return nil }
+ }
+ let resolved = target.resolvingSymlinksInPath().standardizedFileURL.path
+ let base = rootURL.standardizedFileURL.path
+ guard resolved == base || resolved.hasPrefix(base == "/" ? "/" : base + "/") else { return nil }
+ return target
+ }
+
+ static func list(root: String, path: String) -> Result<[[String: Any]], ErrorCode> {
+ guard let url = resolve(root: root, relativePath: path) else { return .failure(.invalidPath) }
+ guard (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
+ return .failure(.unavailable)
+ }
+ do {
+ let children = try FileManager.default.contentsOfDirectory(
+ at: url, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
+ options: []
+ )
+ let sorted = children.sorted {
+ let left = (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
+ let right = (try? $1.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
+ return left == right
+ ? $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending
+ : left
+ }
+ return .success(sorted.prefix(maxEntries).map { child in
+ let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
+ return [
+ "name": child.lastPathComponent,
+ "kind": values?.isSymbolicLink == true ? "link"
+ : (values?.isDirectory == true ? "directory"
+ : (isImage(child.lastPathComponent) ? "image" : "file")),
+ ]
+ })
+ } catch { return .failure(.unavailable) }
+ }
+
+ static func read(root: String, path: String) -> Result {
+ guard !path.isEmpty, let url = resolve(root: root, relativePath: path) else {
+ return .failure(.invalidPath)
+ }
+ guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
+ values.isRegularFile == true else { return .failure(.unavailable) }
+ guard let size = values.fileSize, size <= maxPreviewBytes else { return .failure(.tooLarge) }
+ guard let data = try? Data(contentsOf: url), data.count <= maxPreviewBytes else {
+ return .failure(.unavailable)
+ }
+ guard !data.contains(where: { $0 < 32 && $0 != 9 && $0 != 10 && $0 != 13 }),
+ let text = String(data: data, encoding: .utf8) else {
+ return .failure(.notText)
+ }
+ return .success(text)
+ }
+
+ /// ImageIO decodes only the first frame and bounds the image sent over the
+ /// encrypted socket. The browser receives PNG pixels, never source bytes.
+ static func readImage(root: String, path: String) -> Result {
+ guard !path.isEmpty, isImage(path),
+ let url = resolve(root: root, relativePath: path) else {
+ return .failure(.invalidPath)
+ }
+ guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
+ values.isRegularFile == true else { return .failure(.unavailable) }
+ guard let size = values.fileSize, size <= maxImageBytes else { return .failure(.imageTooLarge) }
+
+ let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions),
+ CGImageSourceGetCount(source) > 0 else { return .failure(.imageUnavailable) }
+ let thumbnailOptions = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceThumbnailMaxPixelSize: 1024,
+ ] as CFDictionary
+ guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else {
+ return .failure(.imageUnavailable)
+ }
+ let output = NSMutableData()
+ guard let destination = CGImageDestinationCreateWithData(
+ output, "public.png" as CFString, 1, nil
+ ) else { return .failure(.imageUnavailable) }
+ CGImageDestinationAddImage(destination, thumbnail, nil)
+ guard CGImageDestinationFinalize(destination) else { return .failure(.imageUnavailable) }
+ let data = output as Data
+ guard data.count <= maxImagePreviewBytes else { return .failure(.imageTooLarge) }
+ return .success(data)
+ }
+
+ enum ErrorCode: String, Error {
+ case invalidPath = "invalid-file-path"
+ case unavailable = "file-unavailable"
+ case tooLarge = "file-too-large"
+ case notText = "file-not-text"
+ case imageTooLarge = "image-too-large"
+ case imageUnavailable = "image-unavailable"
+ }
+}
+
struct WebRemoteAccessToken {
static func generate() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift
index d0300cb9..7e28b9fd 100644
--- a/Glint/WebRemote/WebRemoteServer.swift
+++ b/Glint/WebRemote/WebRemoteServer.swift
@@ -867,6 +867,19 @@ final class WebRemoteServer: @unchecked Sendable {
confirmed: object["confirmed"] as? Bool ?? false,
clientID: clientID
)
+ case "listFiles", "readFile":
+ guard let value = object["workspace"] as? String,
+ value.count <= 36,
+ let workspaceID = UUID(uuidString: value),
+ let pane = object["pane"] as? String,
+ pane.count <= 128,
+ let path = object["path"] as? String,
+ path.utf8.count <= 4096 else {
+ sendError("bad-request", to: clientID)
+ return
+ }
+ accessFiles(workspace: workspaceID, pane: pane, path: path,
+ read: type == "readFile", clientID: clientID)
default:
sendError("unknown-command", to: clientID)
}
@@ -1037,6 +1050,63 @@ final class WebRemoteServer: @unchecked Sendable {
}
}
+ private func accessFiles(workspace: UUID, pane: String, path: String,
+ read: Bool, clientID: UUID) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self, let store = WorkspaceStore.current else { return }
+ guard let root = store.webRemoteFileRoot(workspace: workspace, pane: pane) else {
+ self.queue.async { [weak self] in
+ self?.sendJSON(["type": "fileError", "workspace": workspace.uuidString,
+ "pane": pane, "path": path,
+ "code": "files-unavailable"], to: clientID)
+ }
+ return
+ }
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let response: [String: Any]
+ if read {
+ if WebRemoteFiles.isImage(path) {
+ switch WebRemoteFiles.readImage(root: root, path: path) {
+ case let .success(png):
+ response = ["type": "fileImage", "workspace": workspace.uuidString,
+ "pane": pane,
+ "path": path, "data": png.base64EncodedString()]
+ case let .failure(error):
+ response = ["type": "fileError", "workspace": workspace.uuidString,
+ "pane": pane,
+ "path": path, "code": error.rawValue]
+ }
+ } else {
+ switch WebRemoteFiles.read(root: root, path: path) {
+ case let .success(content):
+ response = ["type": "fileContent", "workspace": workspace.uuidString,
+ "pane": pane,
+ "path": path, "content": content,
+ "format": WebRemoteFiles.isHTML(path) ? "html" : "text"]
+ case let .failure(error):
+ response = ["type": "fileError", "workspace": workspace.uuidString,
+ "pane": pane,
+ "path": path, "code": error.rawValue]
+ }
+ }
+ } else {
+ switch WebRemoteFiles.list(root: root, path: path) {
+ case let .success(entries):
+ response = ["type": "fileList", "workspace": workspace.uuidString,
+ "pane": pane, "root": root,
+ "path": path, "entries": entries,
+ "limit": WebRemoteFiles.maxEntries]
+ case let .failure(error):
+ response = ["type": "fileError", "workspace": workspace.uuidString,
+ "pane": pane,
+ "path": path, "code": error.rawValue]
+ }
+ }
+ self?.queue.async { [weak self] in self?.sendJSON(response, to: clientID) }
+ }
+ }
+ }
+
private func createTerminal(workspace workspaceID: UUID, clientID: UUID) {
DispatchQueue.main.async { [weak self] in
guard let self, let store = WorkspaceStore.current else { return }
diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift
index 9caa338f..0bc3007d 100644
--- a/Glint/Workspace/WorkspaceStore.swift
+++ b/Glint/Workspace/WorkspaceStore.swift
@@ -2914,6 +2914,23 @@ final class WorkspaceStore: ObservableObject {
}
}
+ /// Resolve the selected terminal's current local cwd, not its workspace
+ /// anchor. SSH panes report a local launch cwd as well, so exclude them.
+ func webRemoteFileRoot(workspace id: UUID, pane: String) -> String? {
+ guard let key = Self.parsePaneKey(pane), key.workspace == id,
+ let workspace = workspaces.first(where: { $0.id == id }),
+ !workspace.source.isRemote,
+ let model = workspace.panes[key.pane], model.remoteTarget == nil,
+ workspace.tabs.contains(where: { $0.root.leaves.contains(key.pane) }),
+ surfaceViews[key]?.remoteReviewContext == nil,
+ let cwd = surfaceViews[key]?.currentCwd() ?? model.workingDirectory else { return nil }
+ // SSH does not always publish a remote cwd/title (notably one-shot
+ // commands). Reject its actual PTY process before exposing local cwd.
+ let process = surfaceViews[key]?.foregroundProcessName() ?? paneProcesses[key]
+ guard process?.lowercased() != "ssh" else { return nil }
+ return WebRemoteProjectPath.resolveExistingDirectory(cwd)
+ }
+
func webRemoteThemePayload() -> [String: Any] {
let theme = Theme.current
let accentColor = accent
diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift
index 60953641..38d12e06 100644
--- a/GlintTests/WebRemoteProtocolTests.swift
+++ b/GlintTests/WebRemoteProtocolTests.swift
@@ -1,3 +1,4 @@
+import AppKit
import CryptoKit
import XCTest
@testable import Glint
@@ -490,6 +491,112 @@ final class WebRemoteProtocolTests: XCTestCase {
XCTAssertNil(WebRemoteProjectPath.resolveExistingDirectory("relative/path"))
}
+ func testWebRemoteFilesListsAndReadsOnlyWorkspaceText() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let folder = root.appendingPathComponent("src")
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try Data("hello\n".utf8).write(to: folder.appendingPathComponent("main.swift"))
+ let entries = try WebRemoteFiles.list(root: root.path, path: "src").get()
+ XCTAssertEqual(entries.first?["name"] as? String, "main.swift")
+ XCTAssertEqual(entries.first?["kind"] as? String, "file")
+ XCTAssertEqual(try WebRemoteFiles.read(root: root.path, path: "src/main.swift").get(), "hello\n")
+ XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "../outside"))
+ XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "/etc/passwd"))
+ XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "src/../main.swift"))
+ }
+
+ func testWebRemoteFilesRejectLinksBinaryAndOversize() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createSymbolicLink(
+ at: root.appendingPathComponent("outside"), withDestinationURL: URL(fileURLWithPath: "/etc")
+ )
+ XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "outside/passwd"))
+ try Data([0, 1, 2]).write(to: root.appendingPathComponent("binary"))
+ try Data(repeating: 65, count: WebRemoteFiles.maxPreviewBytes + 1)
+ .write(to: root.appendingPathComponent("large"))
+ if case .failure(.notText) = WebRemoteFiles.read(root: root.path, path: "binary") {
+ } else { XCTFail("Binary content must be rejected") }
+ if case .failure(.tooLarge) = WebRemoteFiles.read(root: root.path, path: "large") {
+ } else { XCTFail("Large content must be rejected") }
+ }
+
+ @MainActor
+ func testWebRemoteFileRootRejectsSSHWithoutRemoteTitleMetadata() throws {
+ let project = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: project) }
+ var workspace = Workspace.fresh(name: "SSH", accentHex: "5E5CE6", symbol: "S")
+ let pane = try XCTUnwrap(workspace.selectedTab?.focusedPane)
+ workspace.panes[pane]?.workingDirectory = project.path
+ // Both ssh host and ssh host command may have no remote cwd/title.
+ XCTAssertNil(workspace.panes[pane]?.remoteTarget)
+ let store = WorkspaceStore(activity: PaneActivityStore())
+ store.workspaces = [workspace]
+ let key = WorkspaceStore.WorkspacePaneKey(workspace: workspace.id, pane: pane)
+ let handle = "\(workspace.id.uuidString):\(pane.value)"
+ store.paneProcesses[key] = "ssh"
+ XCTAssertNil(store.webRemoteFileRoot(workspace: workspace.id, pane: handle))
+ store.paneProcesses[key] = "zsh"
+ XCTAssertEqual(store.webRemoteFileRoot(workspace: workspace.id, pane: handle), project.path)
+ }
+
+ func testWebRemoteImagePreviewUsesWorkspacePathAndReturnsPNG() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ let bitmap = try XCTUnwrap(NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: 8, pixelsHigh: 8,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
+ isPlanar: false, colorSpaceName: .deviceRGB,
+ bytesPerRow: 0, bitsPerPixel: 0
+ ))
+ bitmap.setColor(.red, atX: 0, y: 0)
+ let source = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
+ try source.write(to: root.appendingPathComponent("photo.PNG"))
+
+ XCTAssertTrue(WebRemoteFiles.isImage("photo.PNG"))
+ XCTAssertFalse(WebRemoteFiles.isImage("page.html"))
+ XCTAssertTrue(WebRemoteFiles.isHTML("page.HTM"))
+ let preview = try WebRemoteFiles.readImage(root: root.path, path: "photo.PNG").get()
+ XCTAssertEqual(Array(preview.prefix(8)), [137, 80, 78, 71, 13, 10, 26, 10])
+ XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "../photo.PNG"))
+ }
+
+ @MainActor
+ func testWebRemoteFileRootUsesRequestedTerminalCwd() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let project = root.appendingPathComponent("project")
+ let other = root.appendingPathComponent("other")
+ try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ var workspace = Workspace.fresh(name: "Files", accentHex: "5E5CE6", symbol: "F")
+ let first = try XCTUnwrap(workspace.selectedTab?.focusedPane)
+ workspace.panes[first]?.workingDirectory = project.path
+ workspace.source.repoRoot = project.path
+ let second = PaneID(value: 1)
+ workspace.panes[second] = Pane(id: second, title: "zsh", workingDirectory: other.path)
+ workspace.tabs.append(WorkspaceTab(
+ id: TabID(value: 1), name: nil, root: .leaf(second), focusedPane: second
+ ))
+ let store = WorkspaceStore(activity: PaneActivityStore())
+ store.workspaces = [workspace]
+
+ XCTAssertEqual(store.webRemoteFileRoot(
+ workspace: workspace.id, pane: "\(workspace.id.uuidString):1"
+ ), other.path)
+ XCTAssertNil(store.webRemoteFileRoot(workspace: UUID(), pane: "\(workspace.id.uuidString):1"))
+ XCTAssertNil(store.webRemoteFileRoot(workspace: workspace.id, pane: "\(workspace.id.uuidString):2"))
+ store.workspaces[0].panes[second]?.remoteTarget = "example.com"
+ XCTAssertNil(store.webRemoteFileRoot(
+ workspace: workspace.id, pane: "\(workspace.id.uuidString):1"
+ ))
+ }
+
@MainActor
func testRemoteCloseTargetsRequestedPaneAndRequiresConfirmationForBusyProcess() {
let workspaceID = UUID()