This commit is contained in:
Timothy Jaeryang Baek 2026-11-01 21:30:24 +09:00
parent a1def62b5a
commit a06aa230f7
16 changed files with 513 additions and 472 deletions

View file

@ -6,9 +6,12 @@ Routes:
"""
import asyncio
import json
import logging
import re
from html import escape
import posixpath
from urllib.parse import unquote
from urllib.parse import unquote, urljoin, urlsplit
import aiohttp
from fastapi import APIRouter, Depends, Request, Response, WebSocket
@ -48,6 +51,78 @@ STRIPPED_RESPONSE_HEADERS = frozenset(
)
def _port_browser_html(body: bytes, prefix: str, port: int) -> bytes:
"""Keep port links inside the proxy and report navigation from sandboxed frames."""
config = json.dumps({'prefix': prefix, 'origin': f'http://localhost:{port}'}).replace(
'<', '\\u003c'
)
script = r"""
(() => {
const config = __CONFIG__;
const remote = (value = location.href) => {
const url = new URL(value, location.href);
return url.pathname.startsWith(config.prefix)
? config.origin + '/' + url.pathname.slice(config.prefix.length) + url.search + url.hash
: url.href;
};
const destination = value => new URL(value, remote());
const proxy = value => {
const url = destination(value);
return url.origin === config.origin ? config.prefix + url.pathname.slice(1) + url.search + url.hash : url.href;
};
const report = () => parent.postMessage({type: 'terminal-browser-state', url: remote(), title: document.title}, '*');
for (const method of ['pushState', 'replaceState']) {
const original = history[method].bind(history);
history[method] = (state, title, value) => {
try { const result = original(state, title, value == null ? value : proxy(value)); queueMicrotask(report); return result; }
catch (error) {
if (error.name !== 'SecurityError' || value == null) throw error;
parent.postMessage({type: 'terminal-browser-navigate', url: destination(value).href, replace: method === 'replaceState'}, '*');
}
};
}
addEventListener('popstate', report);
addEventListener('hashchange', report);
document.addEventListener('click', event => {
const link = event.target.closest?.('a[href]');
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.hasAttribute('download')) return;
const url = destination(remote(link.href));
if (!/^https?:$/.test(url.protocol)) return;
event.preventDefault();
parent.postMessage({type: 'terminal-browser-navigate', url: url.href}, '*');
}, true);
document.addEventListener('submit', event => {
const form = event.target;
if (form instanceof HTMLFormElement) form.action = proxy(form.getAttribute('action') || remote());
}, true);
const nativeFetch = window.fetch.bind(window);
window.fetch = (input, init) => nativeFetch(input instanceof Request ? new Request(proxy(input.url), input) : proxy(input), init);
const nativeOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...rest) { return nativeOpen.call(this, method, proxy(url), ...rest); };
addEventListener('DOMContentLoaded', () => {
new MutationObserver(report).observe(document.querySelector('title') || document.head || document.documentElement, {childList:true,subtree:true,characterData:true});
report();
});
report();
})();
""".replace('__CONFIG__', config)
html = body.decode('utf-8', errors='replace')
# Root-relative assets otherwise resolve against WebUI instead of the port.
html = re.sub(
r"""(\b(?:href|src|action)\s*=\s*["'])/(?!/)""",
lambda match: match[1] + escape(prefix, quote=True),
html,
flags=re.I,
)
bridge = '<script>' + script + '</script>'
head = re.search(r'<head\b[^>]*>', html, re.I)
if head:
html = html[:head.end()] + bridge + html[head.end():]
else:
html = bridge + html
return html.encode('utf-8')
def _sanitize_proxy_path(path: str) -> str | None:
"""Sanitize a proxy path to prevent directory traversal / SSRF.
@ -222,6 +297,29 @@ async def proxy_terminal(
if TERMINAL_PROXY_HEADERS:
filtered_headers.update(TERMINAL_PROXY_HEADERS)
port_match = re.match(r'^proxy/(\d+)/', safe_path)
port_prefix = None
if port_match:
port_prefix = request.url.path[
:request.url.path.index('/proxy/') + len(port_match[0]) + 1
]
location = upstream_response.headers.get('location')
if location:
remote_origin = f'http://localhost:{port_match[1]}'
destination = urlsplit(
urljoin(f'{remote_origin}/{safe_path[len(port_match[0]):]}', location)
)
if destination.netloc == urlsplit(remote_origin).netloc:
filtered_headers = {
key: value for key, value in filtered_headers.items()
if key.lower() != 'location'
}
filtered_headers['Location'] = (
port_prefix + destination.path.lstrip('/')
+ (f'?{destination.query}' if destination.query else '')
+ (f'#{destination.fragment}' if destination.fragment else '')
)
# Stream binary responses directly
if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES):
@ -238,6 +336,8 @@ async def proxy_terminal(
# Buffer text/JSON responses
response_body = await upstream_response.read()
if port_prefix and 'text/html' in upstream_content_type:
response_body = _port_browser_html(response_body, port_prefix, int(port_match[1]))
status_code = upstream_response.status
await upstream_response.release()
await session.close()

View file

@ -126,7 +126,7 @@
import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte';
import Navbar from '$lib/components/chat/Navbar.svelte';
import ChatControls from './ChatControls.svelte';
import ChatTabs from './ChatTabs.svelte';
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
import ElicitationDialog from './ElicitationDialog.svelte';
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
@ -5647,7 +5647,7 @@
</div>
{#if !embedded}
<ChatControls
<ChatTabs
bind:terminalContext
bind:history
bind:files

View file

@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest';
import { activateTab, emptyTabs, fileTabId, openTab, removeTab, type PanelTab } from './tabs';
const tab = (id: string, kind: PanelTab['kind'] = 'file'): PanelTab => ({ id, kind, label: id });
describe('chat panel tabs', () => {
it('reuses an open file and keeps files from different terminals separate', () => {
const a = tab(fileTabId('server-a', '/readme.md'));
const b = tab(fileTabId('server-b', '/readme.md'));
let state = openTab(openTab(emptyTabs(), a), b);
state = openTab(state, a);
expect(state.tabs).toHaveLength(2);
expect(state.activeId).toBe(a.id);
});
it('keeps independent shells and returns to the most recently used tab on close', () => {
let state = openTab(
openTab(openTab(emptyTabs(), tab('files', 'files')), tab('shell-1', 'terminal')),
tab('shell-2', 'terminal')
);
state = activateTab(state, 'files');
state = activateTab(state, 'shell-2');
state = removeTab(state, 'shell-2');
expect(state.activeId).toBe('files');
expect(state.tabs.map((item) => item.id)).toEqual(['files', 'shell-1']);
});
it('closes a feature view without automatically reopening it and can reopen it explicitly', () => {
let state = openTab(emptyTabs(), tab('artifacts', 'artifacts'));
state = removeTab(state, 'artifacts');
expect(state).toEqual(emptyTabs());
state = openTab(state, tab('artifacts', 'artifacts'));
expect(state.activeId).toBe('artifacts');
});
it('closing an inactive tab leaves the active tab selected', () => {
const state = removeTab(openTab(openTab(emptyTabs(), tab('a')), tab('b')), 'a');
expect(state.activeId).toBe('b');
expect(state.history).toEqual([]);
});
});

View file

@ -35,7 +35,7 @@
removeTab,
fileTabId,
type PanelTab
} from './ChatControls/tabs';
} from './ChatTabs/tabs';
import {
resolveTerminalConnection,
terminalRequest,
@ -44,15 +44,16 @@
import Dropdown from '../common/Dropdown.svelte';
import DropdownMenu from '../common/DropdownMenu.svelte';
import Icon from './FileNav/Icon.svelte';
import FileTypeIcon from './FileNav/FileTypeIcon.svelte';
import UntitledFile from './ChatControls/UntitledFile.svelte';
import { fileIconName } from './FileNav/fileIcon';
import UntitledFile from './ChatTabs/UntitledFile.svelte';
import Browser from './ChatTabs/Browser.svelte';
import XTerminal from './XTerminal.svelte';
import CallPanel from './MessageInput/CallPanel.svelte';
import Drawer from '../common/Drawer.svelte';
import MobileSwipePanel from '../common/MobileSwipePanel.svelte';
import ResizableSidePanel from '../common/ResizableSidePanel.svelte';
import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
import Embeds from './ChatTabs/Embeds.svelte';
import FileNav from './FileNav.svelte';
import PyodideFileNav from './PyodideFileNav.svelte';
import Overview from './Overview.svelte';
@ -110,22 +111,43 @@
let selectedConnection: TerminalConnection | null = null;
let terminalEnabled = false;
let featureVersion = 0;
let releasePanel: (target: HTMLElement) => void = () => {};
const preservePanel = (target: HTMLElement) => ({ destroy: () => releasePanel(target) });
// Move a single mounted panel between desktop, mobile, and its hidden parking spot.
const placePanel = (node: HTMLElement, target: HTMLElement | null) => {
const home = node.parentElement!;
const move = (destination: HTMLElement | null) => {
const parent = destination ?? home;
if (node.parentElement !== parent) parent.appendChild(node);
const parent = (destination ?? home) as HTMLElement & {
moveBefore?: (node: Node, before: Node | null) => void;
};
if (node.parentElement === parent) return;
if (parent.moveBefore && node.isConnected && parent.isConnected)
parent.moveBefore(node, null);
else parent.appendChild(node);
};
move(target);
return { update: move, destroy: () => node.remove() };
releasePanel = (target) => {
if (node.parentElement === target) move(null);
};
const update = (destination: HTMLElement | null) => {
if (!destination) {
move(null);
return;
}
// Wait until the drawer has attached its destination to the document.
void tick().then(() => {
if ($showTabs && panelTarget === destination && destination.isConnected) move(destination);
});
};
update(target);
return { update, destroy: () => node.remove() };
};
const tabIcon = (kind: PanelTab['kind']) =>
({
files: 'folder',
file: 'page',
terminal: 'terminal',
browser: 'browser',
artifacts: 'code',
preview: 'browser',
voice: 'microphone',
@ -210,6 +232,41 @@
connection: { ...selectedConnection }
});
};
const openBrowser = (port?: number, selector = $selectedTerminalId, baseUrl?: string) => {
const url = port ? `http://localhost:${port}/` : undefined;
const existing =
url &&
tabs.tabs.find(
(tab) => tab.kind === 'browser' && tab.terminalId === selector && tab.path === url
);
if (existing) {
selectTab(existing.id);
return;
}
const connection = selector
? resolveTerminalConnection(
selector,
$terminalServers ?? [],
directTerminals,
localStorage.token ?? ''
)
: null;
if (selector && !terminalAccessible(selector)) return;
const terminal = ($terminalServers ?? []).find((item) => item.id === selector);
const proxyBase =
baseUrl ??
(connection && chatContextNeedsSavedChat(terminal) && chatId
? `${connection.baseUrl}/chats/${encodeURIComponent(chatId)}`
: connection?.baseUrl);
openPanel({
id: crypto.randomUUID(),
kind: 'browser',
label: port ? `localhost:${port}` : $i18n.t('Browser'),
path: url,
terminalId: selector ?? undefined,
browserBaseUrl: proxyBase
});
};
const closeTab = async (id: string) => {
if (closing.has(id)) return;
closing.add(id);
@ -344,13 +401,19 @@
const pruneTabs = () => {
for (const tab of tabs.tabs) {
const available = tab.terminalId
? terminalAccessible(tab.terminalId)
: availableViews.some((view) => view.kind === tab.kind && view.available);
if (!available) tabs = removeTab(tabs, tab.id);
? !!$selectedTerminalId && terminalAccessible(tab.terminalId)
: tab.kind === 'browser' ||
availableViews.some((view) => view.kind === tab.kind && view.available);
if (!available) {
tabs = removeTab(tabs, tab.id);
delete dirtyFiles[tab.id];
delete fileContexts[tab.id];
}
}
};
$: {
availableViews;
$selectedTerminalId;
$terminalServers;
$settings;
$config;
@ -473,6 +536,7 @@
}
const handleMediaQuery = (event: MediaQueryList | MediaQueryListEvent) => {
if (panelTarget && largeScreen !== event.matches) releasePanel(panelTarget);
largeScreen = event.matches;
};
@ -484,6 +548,9 @@
};
onMount(() => {
const unsubscribeVisibility = showTabs.subscribe((visible) => {
if (!visible && panelTarget) releasePanel(panelTarget);
});
const mediaQuery = window.matchMedia('(min-width: 768px)');
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
@ -505,6 +572,7 @@
document.addEventListener('mouseup', onMouseUp);
return () => {
unsubscribeVisibility();
isDestroyed = true;
mounted = false;
if (!largeScreen) {
@ -555,8 +623,8 @@
</Modal>
<div
class:hidden={!panelTarget}
use:placePanel={panelTarget}
class:hidden={!$showTabs || !panelTarget}
use:placePanel={$showTabs ? panelTarget : null}
class="h-full w-full min-h-0 flex flex-col"
id="controls-container"
>
@ -587,7 +655,7 @@
on:click={() => selectTab(tab.id)}
>
{#if tab.kind === 'file'}
<FileTypeIcon name={tab.label} />
<Icon name={fileIconName(tab.label)} size={14} strokeWidth={1.4} />
{:else}
<Icon name={tabIcon(tab.kind)} size={14} />
{/if}
@ -625,6 +693,9 @@
{#if terminalEnabled}<button on:click={newTerminal}
><Icon name="terminal" size={14} />{$i18n.t('New Terminal')}</button
>{/if}
<button on:click={() => openBrowser()}
><Icon name="browser" size={14} />{$i18n.t('Browser')}</button
>
{#each availableViews.filter((view) => view.kind !== 'files' && view.kind !== 'voice' && view.available) as view}
<button
on:click={() =>
@ -679,6 +750,20 @@
onCountChange={setCommentCount}
/>
{/key}
{:else if tab.kind === 'browser'}
<Browser
baseUrl={tab.browserBaseUrl ?? ''}
initialUrl={tab.path ?? ''}
overlay={dragged}
onNavigate={(path, label) => {
tabs = {
...tabs,
tabs: tabs.tabs.map((item) =>
item.id === tab.id ? { ...item, path, label } : item
)
};
}}
/>
{:else if tab.kind === 'terminal' && tab.connection}
<XTerminal
connection={tab.connection}
@ -729,9 +814,10 @@
initialDirectory={tab.kind === 'files' ? (tab.path ?? null) : null}
active={!!$showTabs && tabs.activeId === tab.id}
manageFileRequests={false}
showTerminalDock={false}
showTerminalDock={tab.kind === 'files'}
onOpenFile={(path, page) => openFile(path, tab.terminalId!, page)}
onBrowseDirectory={(path) => openFiles(tab.terminalId, path)}
onOpenPort={(port, baseUrl) => openBrowser(port, tab.terminalId, baseUrl)}
{onAskFile}
overlay={dragged}
{chatId}
@ -774,7 +860,7 @@
swipeSide={$mobile ? 'right' : undefined}
className="min-h-[100dvh] !bg-white dark:!bg-gray-850"
>
<div bind:this={panelTarget} class="h-[100dvh] flex flex-col"></div>
<div use:preservePanel bind:this={panelTarget} class="h-[100dvh] flex flex-col"></div>
</Drawer>
{/if}
</MobileSwipePanel>
@ -787,7 +873,11 @@
storageKey="chatControlsSize"
className="h-full z-10 bg-white dark:bg-gray-900"
>
<div bind:this={panelTarget} class="h-full w-full z-40 pointer-events-auto"></div>
<div
use:preservePanel
bind:this={panelTarget}
class="h-full w-full z-40 pointer-events-auto"
></div>
</ResizableSidePanel>
{/if}

View file

@ -0,0 +1,199 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { getPortProxyUrl } from '$lib/apis/terminal';
import { settings } from '$lib/stores';
import Icon from '../FileNav/Icon.svelte';
import Tooltip from '../../common/Tooltip.svelte';
const i18n = getContext<any>('i18n');
export let baseUrl = '';
export let initialUrl = '';
export let overlay = false;
export let onNavigate: (url: string, label: string) => void = () => {};
export let onClose: (() => void) | null = null;
let urlInput = initialUrl;
let currentUrl = '';
let frameUrl = '';
let frameKey = 0;
let iframe: HTMLIFrameElement;
let addressInput: HTMLInputElement;
let history: string[] = [];
let historyIndex = -1;
let error = '';
let loading = false;
$: canGoBack = historyIndex > 0;
$: canGoForward = historyIndex < history.length - 1;
const address = (value: string): URL => {
const input = value.trim();
const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(input) && !/^[^/]+:\d+(?:[/?#]|$)/.test(input);
const local = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?=[:/?#]|$)/i.test(input);
const url =
/^[/?#]/.test(input) && currentUrl
? new URL(input, currentUrl)
: new URL(hasScheme ? input : `${local ? 'http' : 'https'}://${input}`);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
throw new Error($i18n.t('Enter an HTTP or HTTPS URL without credentials.'));
return url;
};
const localPort = (url: URL) =>
['localhost', '127.0.0.1', '0.0.0.0', '[::1]'].includes(url.hostname)
? Number(url.port || (url.protocol === 'https:' ? 443 : 80))
: null;
const source = (url: URL) => {
const port = localPort(url);
if (port === null) return url.href;
if (!baseUrl) throw new Error($i18n.t('Select a terminal to open localhost URLs.'));
return getPortProxyUrl(baseUrl, port, url.pathname.slice(1) + url.search + url.hash);
};
const remember = (url: string) => {
if (history[historyIndex] === url) return;
history = [...history.slice(0, historyIndex + 1), url];
historyIndex = history.length - 1;
};
const navigate = (value = urlInput, addHistory = true) => {
if (!value.trim()) return;
try {
const url = address(value);
const src = source(url);
currentUrl = url.href;
urlInput = url.href;
if (addHistory) remember(url.href);
frameUrl = src;
frameKey++;
loading = true;
error = '';
onNavigate(url.href, url.host);
} catch (reason) {
error = reason instanceof Error ? reason.message : String(reason);
}
};
const move = (step: number) => {
const next = historyIndex + step;
if (next < 0 || next >= history.length) return;
historyIndex = next;
navigate(history[next], false);
};
const loaded = () => {
loading = false;
try {
const location = iframe.contentWindow?.location;
if (!location || location.href === 'about:blank') return;
const url = new URL(currentUrl);
const port = localPort(url);
let next: URL;
if (port !== null) {
const prefix = new URL(getPortProxyUrl(baseUrl, port), window.location.origin);
if (location.origin !== prefix.origin || !location.pathname.startsWith(prefix.pathname))
return;
next = new URL(
location.pathname.slice(prefix.pathname.length) + location.search + location.hash,
`${url.origin}/`
);
} else next = address(location.href);
currentUrl = next.href;
urlInput = next.href;
remember(next.href);
onNavigate(next.href, iframe.contentDocument?.title || next.host);
} catch {
/* Cross-origin pages do not expose their location or title. */
}
};
const receiveNavigation = (event: MessageEvent) => {
if (event.source !== iframe?.contentWindow || !baseUrl || !currentUrl) return;
const expectedOrigin = new URL(frameUrl, window.location.origin).origin;
if (event.origin !== 'null' && event.origin !== expectedOrigin) return;
const data = event.data;
if (!data || typeof data.url !== 'string') return;
try {
const url = address(data.url);
if (data.type === 'terminal-browser-navigate') {
if (data.replace && historyIndex >= 0) history[historyIndex] = url.href;
navigate(url.href, !data.replace);
} else if (data.type === 'terminal-browser-state') {
if (url.origin !== new URL(currentUrl).origin) return;
currentUrl = url.href;
urlInput = url.href;
remember(url.href);
onNavigate(
url.href,
typeof data.title === 'string' && data.title.trim() ? data.title : url.host
);
}
} catch {
/* Ignore malformed messages from page content. */
}
};
onMount(() => {
window.addEventListener('message', receiveNavigation);
if (initialUrl) navigate(initialUrl);
else addressInput?.focus();
return () => window.removeEventListener('message', receiveNavigation);
});
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center gap-1 px-2 pt-0 pb-1.5">
{#each [{ name: 'Back', icon: 'chevron-left', disabled: !canGoBack, action: () => move(-1) }, { name: 'Forward', icon: 'chevron-right', disabled: !canGoForward, action: () => move(1) }, { name: 'Refresh', icon: 'refresh', disabled: !currentUrl, action: () => navigate(currentUrl, false) }] as button}
<Tooltip content={$i18n.t(button.name)}
><button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded text-gray-500 hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-200"
disabled={button.disabled}
aria-label={$i18n.t(button.name)}
on:click={button.action}
><Icon
name={button.icon}
size={11}
strokeWidth={1.4}
class={button.icon === 'refresh' && loading ? 'animate-spin' : ''}
/></button
></Tooltip
>
{/each}
<form class="min-w-0 flex-1" on:submit|preventDefault={() => navigate()}>
<input
bind:this={addressInput}
bind:value={urlInput}
aria-label={$i18n.t('URL')}
placeholder={$i18n.t('Enter URL')}
spellcheck="false"
class="h-6 w-full rounded-lg bg-gray-50 px-2 text-xs text-gray-700 outline-none dark:bg-gray-850 dark:text-gray-200"
/>
</form>
<Tooltip content={$i18n.t('Open in new tab')}
><button
type="button"
disabled={!frameUrl}
aria-label={$i18n.t('Open in new tab')}
class="flex size-5 shrink-0 items-center justify-center rounded text-gray-500 hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => window.open(frameUrl, '_blank', 'noopener,noreferrer')}
><Icon name="external-link" size={11} strokeWidth={1.4} /></button
></Tooltip
>
{#if onClose}<button
type="button"
aria-label={$i18n.t('Close')}
class="flex size-5 shrink-0 items-center justify-center text-gray-500 dark:text-gray-400"
on:click={onClose}><Icon name="xmark" size={11} /></button
>{/if}
</div>
{#if error}<p role="alert" class="px-2 py-1 text-xs text-red-500">{error}</p>{/if}
<div class="relative min-h-0 flex-1">
{#if overlay}<div class="absolute inset-0 z-10"></div>{/if}
{#if frameUrl}
{#key frameKey}<iframe
bind:this={iframe}
src={frameUrl}
title={$i18n.t('Browser')}
class="h-full w-full border-0 bg-white"
sandbox="allow-scripts{($settings?.terminalPreviewAllowSameOrigin ?? false)
? ' allow-same-origin'
: ''} allow-forms allow-popups allow-modals allow-downloads"
on:load={loaded}
></iframe>{/key}
{:else}<div class="flex h-full items-center justify-center text-xs text-gray-400">
{$i18n.t('Enter a URL to browse.')}
</div>{/if}
</div>
</div>

View file

@ -5,6 +5,7 @@ export type PanelTabKind =
| 'files'
| 'file'
| 'terminal'
| 'browser'
| 'artifacts'
| 'preview'
| 'voice'
@ -19,6 +20,7 @@ export type PanelTab = {
dirty?: boolean;
untitled?: boolean;
connection?: TerminalConnection;
browserBaseUrl?: string;
context?: TerminalContext | null;
};
export type PanelTabs = { tabs: PanelTab[]; activeId: string; history: string[] };

View file

@ -16,7 +16,6 @@
} from '$lib/stores';
import {
getCwd,
getTerminalConfig,
getFileMatches,
listFiles,
readFile,
@ -54,7 +53,7 @@
import FileTypeIcon from './FileNav/FileTypeIcon.svelte';
import BulkActionBar from './FileNav/BulkActionBar.svelte';
import PortList from './FileNav/PortList.svelte';
import PortPreview from './FileNav/PortPreview.svelte';
import Browser from './ChatTabs/Browser.svelte';
import TerminalDock from './TerminalDock.svelte';
// Directory and preview state belong to this tab, including its terminal connection.
@ -74,6 +73,7 @@
export let showTerminalDock = true;
export let onOpenFile: ((path: string, page?: number | null) => void) | null = null;
export let onBrowseDirectory: ((path: string) => void) | null = null;
export let onOpenPort: ((port: number, baseUrl: string) => void) | null = null;
$: effectiveTerminalId = terminalId ?? $selectedTerminalId;
export let chatId: string | null = null;
@ -268,12 +268,12 @@
await tick();
};
// ── Terminal panel state ────────────────────────────────────────────
// ── Sessions panel state ────────────────────────────────────────────
let terminalExpanded = false;
let hasSessions = false;
let terminalHeight = 200; // px, default when expanded
let isDraggingHandle = false;
let containerEl: HTMLElement;
let terminalEnabled = true;
type ComparisonFile = { name: string; path: string; size?: number };
let pinnedComparison: ComparisonFile | null = null;
let comparison: {
@ -359,6 +359,15 @@
let loadingDirs: Set<string> = new Set();
let directoryMenu: { x: number; y: number } | null = null;
let searchQuery = '';
let showSearch = false;
let searchInput: HTMLInputElement;
const toggleSearch = async () => {
showSearch = !showSearch;
if (!showSearch) searchQuery = '';
await tick();
if (showSearch) searchInput?.focus();
else containerEl?.querySelector<HTMLButtonElement>('[data-file-search-toggle]')?.focus();
};
let matchResults: TerminalFileMatch[] | null = null;
let matchLoading = false;
let matchLoadingMore = false;
@ -694,12 +703,6 @@
entries = [];
resetTreeState();
(async () => {
if (terminalChanged) {
const config = await getTerminalConfig(terminal.url, terminal.key);
if (disposed) return;
terminalEnabled = config?.features?.terminal !== false;
}
savedPath = applyCwd(await getCwd(terminal.url, terminal.key, chatId ?? undefined));
loadDir(savedPath);
})();
@ -1916,10 +1919,6 @@
loading = true;
void (async () => {
// Discover server features on initial mount
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
if (disposed) return;
const serverPath = applyCwd(serverCwd);
@ -2100,6 +2099,8 @@
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
{loading}
{showSearch}
onToggleSearch={toggleSearch}
writable={currentWritable}
canGoBack={!initialFile && canGoBack}
canGoForward={!initialFile && canGoForward}
@ -2132,7 +2133,7 @@
{#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
on:click={async () => {
if (!(await protectBuffer())) return;
if (editing) filePreviewRef?.cancelEdit();
@ -2152,7 +2153,7 @@
{#if isHtml && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
@ -2170,7 +2171,7 @@
{:else if isMarkdown && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
@ -2188,7 +2189,7 @@
{:else if isCode}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
@ -2204,7 +2205,7 @@
{:else if editing}
<Tooltip content={$i18n.t('Cancel')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
on:click={() => filePreviewRef?.cancelEdit()}
aria-label={$i18n.t('Cancel')}
>
@ -2213,7 +2214,7 @@
</Tooltip>
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() =>
filePreviewRef?.saveEdit().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
@ -2229,7 +2230,7 @@
{:else}
<Tooltip content={$i18n.t('Edit')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.startEdit()}
disabled={!selectedFileWritable}
aria-label={$i18n.t('Edit')}
@ -2243,7 +2244,7 @@
{#if fileContent !== null}
<Tooltip content={$i18n.t('Copy')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
on:click={async () => {
if (await copyToClipboard(fileContent ?? '')) {
toast.success($i18n.t('Copied to clipboard'));
@ -2257,7 +2258,7 @@
{/if}
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
on:click={() => selectedFile && downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
@ -2266,17 +2267,25 @@
</Tooltip>
</FileNavToolbar>
{#if !selectedFile}
{#if !selectedFile && showSearch}
<div
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-gray-50 px-3 dark:border-gray-850/30"
>
<Icon name="search" size={13} strokeWidth={1.5} class="shrink-0 text-gray-400" />
<input
bind:this={searchInput}
type="text"
class="min-w-0 flex-1 border-none bg-transparent text-xs text-gray-800 outline-none placeholder:text-gray-400 dark:text-gray-200 dark:placeholder:text-gray-600"
placeholder={$i18n.t('Search files and contents')}
aria-label={$i18n.t('Search files and contents')}
bind:value={searchQuery}
on:keydown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
void toggleSearch();
}
}}
/>
{#if searchQuery}
<button
@ -2364,9 +2373,9 @@
}}
>
{#if previewPort !== null}
<PortPreview
<Browser
baseUrl={previewBaseUrl}
port={previewPort}
initialUrl={`http://localhost:${previewPort}/`}
overlay={overlay || isDraggingHandle}
onClose={() => {
previewPort = null;
@ -2714,6 +2723,10 @@
apiKey={selectedTerminal.key}
{chatId}
on:previewPort={(e) => {
if (onOpenPort) {
onOpenPort(e.detail, previewBaseUrl);
return;
}
selectedFile = null;
clearFilePreview();
previewPort = e.detail;
@ -2723,9 +2736,12 @@
</div>
{/if}
<!-- Terminal bottom panel -->
{#if terminalEnabled && showTerminalDock}
<div class="shrink-0 border-t border-gray-50 dark:border-gray-850/30">
<!-- Read-only session output -->
{#if showTerminalDock}
<div
class:hidden={!hasSessions}
class="shrink-0 border-t border-gray-50 dark:border-gray-850/30"
>
{#if terminalExpanded}
<!-- Drag handle (at top of panel) -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
@ -2741,6 +2757,7 @@
<TerminalDock
terminalId={effectiveTerminalId}
{active}
bind:hasSessions
overlay={overlay || isDraggingHandle}
bind:expanded={terminalExpanded}
height={terminalHeight}

View file

@ -19,6 +19,8 @@
export let onNavigate: (path: string) => void = () => {};
export let onRefresh: () => void = () => {};
export let showSearch = false;
export let onToggleSearch: (() => void) | null = null;
export let onNewFolder: () => void = () => {};
export let onNewFile: () => void = () => {};
export let onUploadFiles: (files: File[]) => void = () => {};
@ -65,7 +67,7 @@
<button
type="button"
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
? 'text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
@ -80,7 +82,7 @@
<button
type="button"
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
? 'text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
@ -150,7 +152,7 @@
<Tooltip content={$i18n.t('Refresh')}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
on:click={onRefresh}
aria-label={$i18n.t('Refresh')}
>
@ -162,7 +164,7 @@
<Tooltip content={$i18n.t('Sort')}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
aria-label={$i18n.t('Sort')}
>
<Icon name="sort" size={11} strokeWidth={1.4} />
@ -256,11 +258,25 @@
</DropdownMenu>
</div>
</Dropdown>
{#if onToggleSearch}
<Tooltip content={$i18n.t('Search')}>
<button
type="button"
data-file-search-toggle
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
aria-label={$i18n.t('Search')}
aria-expanded={showSearch}
on:click={onToggleSearch}
>
<Icon name="search" size={11} strokeWidth={1.4} />
</button>
</Tooltip>
{/if}
<Dropdown bind:show={actionsMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Actions')}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
aria-label={$i18n.t('Actions')}
>
<Icon name="three-dots" size={11} strokeWidth={1.4} />

View file

@ -98,7 +98,7 @@
{:else}
{#each ports as port}
<div
class="flex h-7 items-center w-full gap-2 px-1.5 text-xs rounded-lg hover:bg-gray-50/40 dark:hover:bg-white/4 transition-colors duration-75 group"
class="flex h-7 items-center w-full gap-2 px-1.5 text-xs rounded-lg group"
>
<button
class="flex min-w-0 flex-1 items-center gap-2 self-stretch"

View file

@ -1,300 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getPortProxyUrl } from '$lib/apis/terminal';
import { settings } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let baseUrl: string;
export let port: number;
export let path: string = '';
export let onClose: () => void = () => {};
export let overlay = false;
let iframeEl: HTMLIFrameElement;
let urlInput: string = '';
let iframeKey = 0;
let isLoading = false;
// ── Navigation history ──────────────────────────────────────────────
let history: string[] = [path];
let historyIndex = 0;
$: canGoBack = historyIndex > 0;
$: canGoForward = historyIndex < history.length - 1;
const pushHistory = (newPath: string) => {
if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1);
}
history = [...history, newPath];
historyIndex = history.length - 1;
};
const goBack = () => {
if (!canGoBack) return;
historyIndex -= 1;
path = history[historyIndex];
syncUrlBar();
iframeKey += 1;
};
const goForward = () => {
if (!canGoForward) return;
historyIndex += 1;
path = history[historyIndex];
syncUrlBar();
iframeKey += 1;
};
// ── URLs ─────────────────────────────────────────────────────────────
$: proxyUrl = getPortProxyUrl(baseUrl, port, path);
$: proxyPathPrefix = (() => {
try {
return new URL(getPortProxyUrl(baseUrl, port, ''), window.location.origin).pathname;
} catch {
return `/proxy/${port}/`;
}
})();
const makeDisplayUrl = (p: string) => `localhost:${port}${p ? '/' + p : ''}`;
const syncUrlBar = () => {
urlInput = makeDisplayUrl(path);
};
urlInput = makeDisplayUrl(path);
const refresh = () => {
iframeKey += 1;
};
const openExternal = () => {
window.open(proxyUrl, '_blank', 'noopener,noreferrer');
};
const navigateUrl = () => {
const localhostPrefix = `localhost:${port}`;
const stripped = urlInput.trim();
let newPath = '';
if (stripped.startsWith(localhostPrefix)) {
newPath = stripped.slice(localhostPrefix.length).replace(/^\//, '');
} else if (stripped.startsWith('/') || !stripped.includes(':')) {
newPath = stripped.replace(/^\//, '');
}
if (newPath !== path) {
path = newPath;
pushHistory(path);
}
syncUrlBar();
iframeKey += 1;
};
/**
* Read the iframe's current location and sync the URL bar.
* If the iframe escaped the proxy prefix, redirect it back.
*/
const onIframeLoad = () => {
isLoading = false;
if (!iframeEl) return;
try {
const loc = iframeEl.contentWindow?.location;
if (!loc) return;
const iframePath = loc.pathname ?? '';
const iframeSearch = loc.search ?? '';
const iframeHash = loc.hash ?? '';
if (iframePath.startsWith(proxyPathPrefix)) {
const relativePath = iframePath.slice(proxyPathPrefix.length) + iframeSearch + iframeHash;
if (relativePath !== path) {
path = relativePath;
pushHistory(path);
syncUrlBar();
}
}
} catch {
// Cross-origin — can't access
}
};
</script>
<div class="flex flex-col h-full min-h-0">
<!-- Browser chrome -->
<div
class="flex items-center gap-1 px-1.5 py-1 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 shrink-0"
>
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="p-1 rounded transition {canGoBack
? 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
: 'text-gray-300 dark:text-gray-700 cursor-default'}"
on:click={goBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="p-1 rounded transition {canGoForward
? 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
: 'text-gray-300 dark:text-gray-700 cursor-default'}"
on:click={goForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- Refresh -->
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={refresh}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
class:animate-spin={isLoading}
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- URL bar -->
<form class="flex-1 min-w-0" on:submit|preventDefault={navigateUrl}>
<input
aria-label={$i18n.t('URL')}
type="text"
bind:value={urlInput}
class="w-full text-[0.6875rem] font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full px-3 py-1 outline-hidden focus:border-gray-400 dark:focus:border-gray-500 text-gray-600 dark:text-gray-300 transition"
placeholder="localhost:{port}"
/>
</form>
<!-- Open in new tab -->
<Tooltip content={$i18n.t('Open in new tab')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={openExternal}
aria-label={$i18n.t('Open in new tab')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- Close -->
<Tooltip content={$i18n.t('Close')}>
<button
class="py-1 rounded text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition px-0"
on:click={onClose}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</Tooltip>
</div>
<!-- Loading bar -->
{#if isLoading}
<div class="h-0.5 bg-gray-100 dark:bg-gray-800 shrink-0 overflow-hidden">
<div class="h-full bg-blue-500 animate-loading-bar rounded-full" />
</div>
{/if}
<!-- Iframe -->
<div class="flex-1 min-h-0 relative">
{#if overlay}
<div class="absolute inset-0 z-10"></div>
{/if}
{#key iframeKey}
<iframe
bind:this={iframeEl}
src={proxyUrl}
title="Port {port} preview"
class="w-full h-full border-0 bg-white"
sandbox="allow-scripts{($settings?.terminalPreviewAllowSameOrigin ?? false)
? ' allow-same-origin'
: ''} allow-forms allow-popups allow-modals allow-downloads"
on:load={onIframeLoad}
/>
{/key}
</div>
</div>
<style>
@keyframes loading-bar {
0% {
width: 0;
margin-left: 0;
}
50% {
width: 60%;
margin-left: 20%;
}
100% {
width: 0;
margin-left: 100%;
}
}
.animate-loading-bar {
animation: loading-bar 1.5s ease-in-out infinite;
}
</style>

View file

@ -127,7 +127,7 @@
$: triggerClass = `flex items-center gap-1.5 translate-y-[1px] text-[0.8125rem] transition rounded-lg ${
disabled
? 'p-1 cursor-not-allowed opacity-40 text-gray-400 dark:text-gray-600'
: `text-gray-600 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-200 cursor-pointer ${
: `text-gray-600 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200 cursor-pointer ${
$selectedTerminalId && selectedLabel ? ' p-1 ' : ' p-1 opacity-50'
}`
}`;

View file

@ -18,9 +18,8 @@
export let expanded = false;
export let height = 200;
export let overlay = false;
export let connected = false;
export let connecting = false;
export let running = 0;
export let hasSessions = false;
const i18n = getContext<Readable<{ t: (key: string) => string }>>('i18n');
type Tab = TerminalProcess & {
@ -31,9 +30,7 @@
};
let connection: TerminalConnection | null = null;
let tabs: Tab[] = [];
let activeId = 'shell';
let shellOpened = false;
let shellDismissed = false;
let activeId = '';
let error = '';
let panes: Record<string, XTerminal> = {};
const dismissed = new Set<string>();
@ -41,11 +38,13 @@
let polling = false;
let disposed = false;
$: if (expanded && activeId === 'shell') shellOpened = true;
$: if (active) void tick().then(poll);
$: if (expanded) void tick().then(poll);
$: hasSessions = tabs.length > 0;
$: running = tabs.filter((tab) => tab.status === 'running' && tab.available).length;
async function poll() {
if (!connection || polling || disposed || document.hidden) return;
if (!connection || !active || polling || disposed || document.hidden) return;
polling = true;
try {
const processes = await terminalRequest<TerminalProcess[]>(connection, chatId, '/execute', {
@ -64,6 +63,7 @@
];
}
}
if (!tabs.some((tab) => tab.id === activeId)) activeId = tabs[0]?.id ?? '';
const tab = tabs.find((tab) => tab.id === activeId);
if (expanded && tab?.available && !tab.finished) {
await tick();
@ -88,7 +88,7 @@
exit_code: result.exit_code,
offset: result.next_offset,
loaded: true,
finished: result.status === 'done'
finished: result.status !== 'running'
}
: item
);
@ -103,10 +103,6 @@
function select(id: string) {
expanded = true;
activeId = id;
if (id === 'shell') {
shellOpened = true;
shellDismissed = false;
}
tick().then(poll);
}
@ -114,21 +110,12 @@
dismissed.add(id);
tabs = tabs.filter((tab) => tab.id !== id);
delete panes[id];
if (activeId === id) activeId = shellOpened ? 'shell' : (tabs[0]?.id ?? '');
if (!activeId) expanded = false;
}
function closeShell() {
shellOpened = false;
shellDismissed = true;
connected = false;
connecting = false;
activeId = tabs[0]?.id ?? '';
if (activeId === id) activeId = tabs[0]?.id ?? '';
if (!activeId) expanded = false;
}
function navigate(event: KeyboardEvent) {
const ids = [...(!shellDismissed ? ['shell'] : []), ...tabs.map((tab) => tab.id)];
const ids = tabs.map((tab) => tab.id);
const index = ids.indexOf(activeId);
let next: string | undefined;
if (event.key === 'ArrowRight') next = ids[(index + 1) % ids.length];
@ -169,52 +156,25 @@
>
{#if !expanded}
<button
on:click={() => select(activeId || 'shell')}
on:click={() => select(activeId)}
class="flex h-full min-w-0 flex-1 items-center gap-1.5 px-2 text-left hover:text-gray-900 dark:hover:text-gray-100"
>
<Icon name="terminal" size={13} />
<span>{$i18n.t('Terminal')}</span>
<span>{$i18n.t('Sessions')}</span>
{#if running}<span class="tabular-nums text-gray-400">{running}</span>{/if}
</button>
{:else}
<span class="flex shrink-0 items-center gap-1.5 px-2">
<Icon name="terminal" size={13} />
<span>{$i18n.t('Sessions')}</span>
</span>
<div
role="tablist"
tabindex="-1"
aria-label={$i18n.t('Terminal')}
aria-label={$i18n.t('Sessions')}
on:keydown={navigate}
class="terminal-tabs flex h-full min-w-0 flex-1 items-stretch overflow-x-auto"
>
{#if !shellDismissed}
<div
class="terminal-tab group flex shrink-0 items-center border-b"
class:selected={activeId === 'shell'}
>
<button
role="tab"
data-tab="shell"
aria-selected={activeId === 'shell'}
tabindex={activeId === 'shell' ? 0 : -1}
on:click={() => select('shell')}
class="tab-button flex h-full items-center gap-1.5 pl-2 pr-1"
title={$i18n.t('Shell')}
>
<Icon name="terminal" size={12} /><span>{$i18n.t('Shell')}</span>
</button>
{#if shellOpened}<button
on:click={closeShell}
class="tab-close mr-1 flex h-5 w-4 items-center justify-center"
title={$i18n.t('Close terminal')}
aria-label={$i18n.t('Close terminal')}><Icon name="xmark" size={10} /></button
>{/if}
</div>
{:else}
<button
class="tab-button flex w-7 shrink-0 items-center justify-center"
on:click={() => select('shell')}
title={$i18n.t('Open terminal')}
aria-label={$i18n.t('Open terminal')}><Icon name="plus" size={12} /></button
>
{/if}
{#each tabs as tab (tab.id)}
<div
class="terminal-tab group flex shrink-0 items-center border-b"
@ -250,18 +210,18 @@
</div>
{/if}
<button
on:click={() => (expanded ? (expanded = false) : select(activeId || 'shell'))}
on:click={() => (expanded ? (expanded = false) : select(activeId))}
class="tab-button flex h-7 w-7 shrink-0 items-center justify-center"
aria-expanded={expanded}
title={expanded ? $i18n.t('Collapse terminal') : $i18n.t('Expand terminal')}
aria-label={expanded ? $i18n.t('Collapse terminal') : $i18n.t('Expand terminal')}
title={expanded ? $i18n.t('Collapse sessions') : $i18n.t('Expand sessions')}
aria-label={expanded ? $i18n.t('Collapse sessions') : $i18n.t('Expand sessions')}
>
<Icon name={expanded ? 'chevron-down' : 'chevron-up'} size={12} />
</button>
</div>
<div
style:height={`${height}px`}
class="flex min-h-0 flex-col bg-white dark:bg-black"
class="flex min-h-0 flex-col bg-white dark:bg-transparent"
class:hidden={!expanded}
>
{#if error}<div
@ -271,18 +231,6 @@
{error}
</div>{/if}
<div class="relative min-h-0 flex-1">
{#if connection && shellOpened}
<div class="absolute inset-0" class:hidden={activeId !== 'shell'}>
<XTerminal
{connection}
{chatId}
{overlay}
active={active && expanded && activeId === 'shell'}
bind:connected
bind:connecting
/>
</div>
{/if}
{#if connection}
{#each tabs as tab (tab.id)}
{#if tab.loaded || activeId === tab.id}

View file

@ -52,10 +52,10 @@
if (!term) return;
term.options.theme = document.documentElement.classList.contains('dark')
? {
background: '#000000',
background: '#00000000',
foreground: '#c0c0c0',
cursor: '#ffffff',
cursorAccent: '#000000',
cursorAccent: '#171717',
selectionBackground: '#444444'
}
: {
@ -134,6 +134,7 @@
onMount(() => {
term = new Terminal({
allowTransparency: true,
cursorBlink: !readOnly,
disableStdin: readOnly,
fontSize: 13,
@ -182,10 +183,16 @@
});
</script>
<div class="h-full min-h-0 relative bg-white dark:bg-black">
<div class="terminal-view h-full min-h-0 relative bg-white dark:bg-transparent">
<div
bind:this={terminalEl}
class="absolute inset-0 px-0.5"
class="absolute inset-0 pl-2 pr-0.5"
class:pointer-events-none={overlay}
></div>
</div>
<style>
.terminal-view :global(.xterm-viewport) {
background-color: transparent;
}
</style>

View file

@ -54,7 +54,7 @@
});
$: if (show && modalElement) {
document.body.appendChild(modalElement);
if (modalElement.parentElement !== document.body) document.body.appendChild(modalElement);
if (!focusTrap) {
focusTrap = FocusTrap.createFocusTrap(modalElement, {
escapeDeactivates: false,