mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
refac
This commit is contained in:
parent
a1def62b5a
commit
a06aa230f7
16 changed files with 513 additions and 472 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
||||
199
src/lib/components/chat/ChatTabs/Browser.svelte
Normal file
199
src/lib/components/chat/ChatTabs/Browser.svelte
Normal 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>
|
||||
|
|
@ -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[] };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
}`
|
||||
}`;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue