1
0
forked from wrenn/wrenn
This commit is contained in:
2026-04-16 19:24:25 +00:00
parent 172413e91e
commit 605ad666a0
239 changed files with 19966 additions and 3454 deletions

View File

@ -0,0 +1,40 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
import type { Capsule, CreateCapsuleParams, Snapshot } from '$lib/api/capsules';
import type { AdminTemplate } from '$lib/api/builds';
export async function listAdminCapsules(): Promise<ApiResult<Capsule[]>> {
return apiFetch('GET', '/api/v1/admin/capsules');
}
export async function getAdminCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('GET', `/api/v1/admin/capsules/${id}`);
}
export async function createAdminCapsule(params: CreateCapsuleParams): Promise<ApiResult<Capsule>> {
return apiFetch('POST', '/api/v1/admin/capsules', params);
}
export async function destroyAdminCapsule(id: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/admin/capsules/${id}`);
}
export async function snapshotAdminCapsule(id: string, name?: string): Promise<ApiResult<Snapshot>> {
return apiFetch('POST', `/api/v1/admin/capsules/${id}/snapshot`, { name });
}
/** Fetch platform templates for the admin create dialog. */
export async function listPlatformTemplates(): Promise<ApiResult<Snapshot[]>> {
const result = await apiFetch<AdminTemplate[]>('GET', '/api/v1/admin/templates');
if (!result.ok) return result;
// Map AdminTemplate → Snapshot shape.
const snapshots: Snapshot[] = result.data.map((t) => ({
name: t.name,
type: t.type,
vcpus: t.vcpus || undefined,
memory_mb: t.memory_mb || undefined,
size_bytes: t.size_bytes,
created_at: t.created_at,
platform: true,
}));
return { ok: true, data: snapshots };
}

View File

@ -0,0 +1,28 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type AdminUser = {
id: string;
email: string;
name: string;
is_admin: boolean;
status: string;
created_at: string;
teams_joined: number;
teams_owned: number;
};
export type AdminUsersResponse = {
users: AdminUser[];
total: number;
page: number;
per_page: number;
total_pages: number;
};
export async function listAdminUsers(page: number = 1): Promise<ApiResult<AdminUsersResponse>> {
return apiFetch('GET', `/api/v1/admin/users?page=${page}`);
}
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
}

View File

@ -6,17 +6,26 @@ export type AuthResponse = {
name: string;
};
export type SignupResponse = {
message: string;
};
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string };
export type SignupResult = { ok: true; data: SignupResponse } | { ok: false; error: string };
export async function apiLogin(email: string, password: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/login', { email, password });
}
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> {
export async function apiSignup(email: string, password: string, name: string): Promise<SignupResult> {
return authFetch('/api/v1/auth/signup', { email, password, name });
}
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {
export async function apiActivate(token: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/activate', { token });
}
async function authFetch<T = AuthResponse>(url: string, body: Record<string, string>): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
try {
const res = await fetch(url, {
method: 'POST',
@ -31,7 +40,7 @@ async function authFetch(url: string, body: Record<string, string>): Promise<Aut
return { ok: false, error: message };
}
return { ok: true, data: data as AuthResponse };
return { ok: true, data: data as T };
} catch {
return { ok: false, error: 'Unable to connect to the server' };
}

View File

@ -1,4 +1,4 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
export type BuildLogEntry = {
step: number;
@ -26,6 +26,8 @@ export type Build = {
error?: string;
sandbox_id?: string;
host_id?: string;
default_user: string;
default_env: Record<string, string>;
created_at: string;
started_at?: string;
completed_at?: string;
@ -39,9 +41,18 @@ export type CreateBuildParams = {
vcpus?: number;
memory_mb?: number;
skip_pre_post?: boolean;
archive?: File;
};
export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> {
if (params.archive) {
// Use multipart when an archive file is provided.
const { archive, ...config } = params;
const formData = new FormData();
formData.append('config', JSON.stringify(config));
formData.append('archive', archive);
return apiFetchMultipart('POST', '/api/v1/admin/builds', formData);
}
return apiFetch('POST', '/api/v1/admin/builds', params);
}

View File

@ -17,11 +17,11 @@ export type Capsule = {
export async function listCapsules(): Promise<ApiResult<Capsule[]>> {
return apiFetch('GET', '/api/v1/sandboxes');
return apiFetch('GET', '/api/v1/capsules');
}
export async function getCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}`);
return apiFetch('GET', `/api/v1/capsules/${id}`);
}
export type CreateCapsuleParams = {
@ -32,19 +32,19 @@ export type CreateCapsuleParams = {
};
export async function createCapsule(params: CreateCapsuleParams): Promise<ApiResult<Capsule>> {
return apiFetch('POST', '/api/v1/sandboxes', params);
return apiFetch('POST', '/api/v1/capsules', params);
}
export async function pauseCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('POST', `/api/v1/sandboxes/${id}/pause`);
return apiFetch('POST', `/api/v1/capsules/${id}/pause`);
}
export async function resumeCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('POST', `/api/v1/sandboxes/${id}/resume`);
return apiFetch('POST', `/api/v1/capsules/${id}/resume`);
}
export async function destroyCapsule(id: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/sandboxes/${id}`);
return apiFetch('DELETE', `/api/v1/capsules/${id}`);
}
export type Snapshot = {
@ -57,8 +57,8 @@ export type Snapshot = {
platform: boolean;
};
export async function createSnapshot(sandboxId: string, name?: string): Promise<ApiResult<Snapshot>> {
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name });
export async function createSnapshot(capsuleId: string, name?: string): Promise<ApiResult<Snapshot>> {
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name });
}
export async function listSnapshots(typeFilter?: string): Promise<ApiResult<Snapshot[]>> {

View File

@ -22,3 +22,24 @@ export async function apiFetch<T>(method: string, path: string, body?: unknown):
return { ok: false, error: 'Unable to connect to the server' };
}
}
export async function apiFetchMultipart<T>(method: string, path: string, formData: FormData): Promise<ApiResult<T>> {
try {
const headers: Record<string, string> = {};
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(path, {
method,
headers,
body: formData
});
if (res.status === 204) return { ok: true, data: undefined as T };
const data = await res.json();
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
return { ok: true, data: data as T };
} catch {
return { ok: false, error: 'Unable to connect to the server' };
}
}

View File

@ -0,0 +1,141 @@
import { auth } from '$lib/auth.svelte';
import { type ApiResult } from '$lib/api/client';
export type FileEntry = {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink' | 'unknown';
size: number;
mode: number;
permissions: string;
owner: string;
group: string;
modified_at: number;
symlink_target?: string | null;
};
export type ListDirResponse = {
entries: FileEntry[];
};
const MAX_READABLE_SIZE = 10 * 1024 * 1024; // 10 MB
/**
* Whether a file can be previewed as text in the browser.
* Binary/unreadable extensions and files > 10 MB should be downloaded instead.
*/
const BINARY_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.svg',
'.mp3', '.mp4', '.wav', '.ogg', '.flac', '.avi', '.mkv', '.mov', '.webm',
'.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar', '.zst',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc',
'.woff', '.woff2', '.ttf', '.otf', '.eot',
'.db', '.sqlite', '.sqlite3',
'.iso', '.img', '.dmg',
]);
export function isBinaryFile(name: string): boolean {
const dot = name.lastIndexOf('.');
if (dot === -1) return false;
return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
}
export function isFileTooLarge(size: number): boolean {
return size > MAX_READABLE_SIZE;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const val = bytes / Math.pow(1024, i);
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
}
export async function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise<ApiResult<ListDirResponse>> {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(`${basePath}/${capsuleId}/files/list`, {
method: 'POST',
headers,
body: JSON.stringify({ path, depth }),
});
const data = await res.json();
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Failed to list directory' };
return { ok: true, data: data as ListDirResponse };
} catch {
return { ok: false, error: 'Unable to connect to the server' };
}
}
export async function readFile(
capsuleId: string,
path: string,
signal?: AbortSignal,
basePath = '/api/v1/capsules',
): Promise<ApiResult<string>> {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
method: 'POST',
headers,
body: JSON.stringify({ path }),
signal,
});
if (!res.ok) {
try {
const data = await res.json();
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
} catch {
return { ok: false, error: `HTTP ${res.status}` };
}
}
const blob = await res.blob();
const text = await blob.text();
return { ok: true, data: text };
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
return { ok: false, error: 'Request aborted' };
}
return { ok: false, error: 'Unable to connect to the server' };
}
}
export async function downloadFile(
capsuleId: string,
path: string,
filename: string,
signal?: AbortSignal,
basePath = '/api/v1/capsules',
): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
method: 'POST',
headers,
body: JSON.stringify({ path }),
signal,
});
if (!res.ok) throw new Error('Download failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Delay revocation so the browser has time to start the download
setTimeout(() => URL.revokeObjectURL(url), 5000);
}

View File

@ -0,0 +1,42 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
import type { AuthResponse } from '$lib/api/auth';
export type MeResponse = {
name: string;
email: string;
has_password: boolean;
providers: string[];
};
export type ChangePasswordBody = {
current_password?: string;
new_password: string;
confirm_password?: string;
};
export const getMe = (): Promise<ApiResult<MeResponse>> =>
apiFetch('GET', '/api/v1/me');
export const updateName = (name: string): Promise<ApiResult<AuthResponse>> =>
apiFetch('PATCH', '/api/v1/me', { name });
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
apiFetch('POST', '/api/v1/me/password', body);
export const requestPasswordReset = (email: string): Promise<ApiResult<void>> =>
apiFetch('POST', '/api/v1/me/password/reset', { email });
export const confirmPasswordReset = (
token: string,
new_password: string
): Promise<ApiResult<void>> =>
apiFetch('POST', '/api/v1/me/password/reset/confirm', { token, new_password });
export const getProviderConnectURL = (provider: string): Promise<ApiResult<{ auth_url: string }>> =>
apiFetch('GET', `/api/v1/me/providers/${provider}/connect`);
export const disconnectProvider = (provider: string): Promise<ApiResult<void>> =>
apiFetch('DELETE', `/api/v1/me/providers/${provider}`);
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
apiFetch('DELETE', '/api/v1/me', { confirmation });

View File

@ -15,11 +15,20 @@ export type MetricsResponse = {
points: MetricPoint[];
};
export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`);
export async function fetchCapsuleMetrics(id: string, range: MetricRange, basePath = '/api/v1/capsules'): Promise<ApiResult<MetricsResponse>> {
return apiFetch('GET', `${basePath}/${id}/metrics?range=${range}`);
}
export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h'];
// All ranges poll every 10 seconds.
// Poll interval varies by range — shorter ranges need fresher data.
export const METRIC_POLL_INTERVALS: Record<MetricRange, number> = {
'5m': 10_000,
'10m': 10_000,
'1h': 30_000,
'6h': 60_000,
'24h': 120_000,
};
/** @deprecated Use METRIC_POLL_INTERVALS instead */
export const METRIC_POLL_INTERVAL = 10_000;

View File

@ -24,7 +24,7 @@ export type StatsResponse = {
};
export async function fetchStats(range: TimeRange): Promise<ApiResult<StatsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/stats?range=${range}`);
return apiFetch('GET', `/api/v1/capsules/stats?range=${range}`);
}
export const POLL_INTERVALS: Record<TimeRange, number> = {

View File

@ -83,3 +83,39 @@ export async function leaveTeam(id: string): Promise<ApiResult<void>> {
export async function searchUsers(email: string): Promise<ApiResult<UserSearchResult[]>> {
return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`);
}
// Admin team types and API functions
export type AdminTeam = {
id: string;
name: string;
slug: string;
is_byoc: boolean;
created_at: string;
deleted_at: string | null;
member_count: number;
owner_name: string;
owner_email: string;
active_sandbox_count: number;
channel_count: number;
};
export type AdminTeamsResponse = {
teams: AdminTeam[];
total: number;
page: number;
per_page: number;
total_pages: number;
};
export async function listAdminTeams(page: number = 1): Promise<ApiResult<AdminTeamsResponse>> {
return apiFetch('GET', `/api/v1/admin/teams?page=${page}`);
}
export async function adminSetBYOC(id: string, enabled: boolean): Promise<ApiResult<void>> {
return apiFetch('PUT', `/api/v1/admin/teams/${id}/byoc`, { enabled });
}
export async function adminDeleteTeam(id: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/admin/teams/${id}`);
}

View File

@ -3,14 +3,17 @@
import { auth } from '$lib/auth.svelte';
import {
IconServer,
IconTemplate,
IconBox,
IconMonitor,
IconSettings,
IconLogout,
IconSidebar,
IconBell,
IconDocs,
IconChevron,
IconShield
IconShield,
IconMembers,
IconUser
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
@ -22,8 +25,14 @@
};
const managementItems: NavItem[] = [
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' },
{ label: 'Templates', icon: IconTemplate, href: '/admin/templates' }
{ label: 'Users', icon: IconUser, href: '/admin/users' },
{ label: 'Teams', icon: IconMembers, href: '/admin/teams' }
];
const platformItems: NavItem[] = [
{ label: 'Templates', icon: IconBox, href: '/admin/templates' },
{ label: 'Capsules', icon: IconMonitor, href: '/admin/capsules' },
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' }
];
function isActive(href: string): boolean {
@ -96,43 +105,8 @@
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 {collapsed ? 'px-1.5' : ''}">
<div class="mb-3">
{#if !collapsed}
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
Management
</div>
{:else}
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
{/if}
{#each managementItems as item}
{#if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
{#if !collapsed}
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
{#if !collapsed}
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
{/if}
</a>
{:else}
<a
href={item.href}
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
{@render navSection('Platform', platformItems)}
{@render navSection('Management', managementItems)}
</nav>
<!-- Bottom links -->
@ -184,3 +158,43 @@
</button>
</div>
</aside>
{#snippet navSection(title: string, items: NavItem[])}
<div class="mb-3">
{#if !collapsed}
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
{title}
</div>
{:else}
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
{/if}
{#each items as item}
{#if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
{#if !collapsed}
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
{#if !collapsed}
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
{/if}
</a>
{:else}
<a
href={item.href}
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
{/snippet}

View File

@ -1,210 +0,0 @@
<script lang="ts">
import { Dialog } from 'bits-ui';
import {
IconGithub,
IconMail,
IconLock,
IconUser,
IconX,
IconEye,
IconEyeOff
} from './icons';
let {
mode = $bindable('signin'),
open = $bindable(false),
onSwitchMode
}: {
mode: 'signin' | 'signup';
open: boolean;
onSwitchMode: () => void;
} = $props();
let email = $state('');
let password = $state('');
let name = $state('');
let showPassword = $state(false);
const title = $derived(mode === 'signin' ? 'Welcome back' : 'Create account');
const subtitle = $derived(
mode === 'signin' ? 'Sign in to your Wrenn account' : 'Get started with Wrenn'
);
const submitLabel = $derived(mode === 'signin' ? 'Sign in' : 'Create account');
const switchText = $derived(
mode === 'signin' ? "Don't have an account?" : 'Already have an account?'
);
const switchAction = $derived(mode === 'signin' ? 'Sign up' : 'Sign in');
function handleSubmit(e: Event) {
e.preventDefault();
}
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-[3px]"
style="animation: overlayFadeIn 200ms ease"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-0"
style="animation: contentSlideIn 250ms cubic-bezier(0.16, 1, 0.3, 1)"
>
<!-- Close button -->
<Dialog.Close
class="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] border border-transparent text-[var(--color-text-tertiary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-secondary)]"
>
<IconX size={14} />
</Dialog.Close>
<div class="px-7 pb-7 pt-8">
<!-- Header -->
<div class="mb-7">
<Dialog.Title
class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]"
>
{title}
</Dialog.Title>
<Dialog.Description
class="mt-1 text-ui text-[var(--color-text-secondary)]"
>
{subtitle}
</Dialog.Description>
</div>
<!-- GitHub OAuth -->
<button
type="button"
class="flex w-full items-center justify-center gap-2.5 rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-4 py-2.5 text-ui font-medium text-[var(--color-text-bright)] transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]"
>
<IconGithub size={16} />
Continue with GitHub
</button>
<!-- Divider -->
<div class="my-5 flex items-center gap-3">
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
<span
class="font-mono text-badge uppercase tracking-[0.1em] text-[var(--color-text-muted)]"
>or</span
>
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-3">
{#if mode === 'signup'}
<div class="group relative">
<div
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
>
<IconUser size={14} />
</div>
<input
type="text"
bind:value={name}
placeholder="Full name"
autocomplete="name"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-2.5 pl-9 pr-3 text-ui text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
/>
</div>
{/if}
<div class="group relative">
<div
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
>
<IconMail size={14} />
</div>
<input
type="email"
bind:value={email}
placeholder="Email address"
autocomplete="email"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-2.5 pl-9 pr-3 text-ui text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
/>
</div>
<div class="group relative">
<div
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
>
<IconLock size={14} />
</div>
<input
type={showPassword ? 'text' : 'password'}
bind:value={password}
placeholder="Password"
autocomplete={mode === 'signin' ? 'current-password' : 'new-password'}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-2.5 pl-9 pr-10 text-ui text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
tabindex={-1}
>
{#if showPassword}
<IconEyeOff size={14} />
{:else}
<IconEye size={14} />
{/if}
</button>
</div>
{#if mode === 'signin'}
<div class="flex justify-end">
<button
type="button"
class="text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
>
Forgot password?
</button>
</div>
{/if}
<button
type="submit"
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
{submitLabel}
</button>
</form>
<!-- Switch mode -->
<p class="mt-5 text-center text-meta text-[var(--color-text-secondary)]">
{switchText}
<button
type="button"
onclick={onSwitchMode}
class="font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:text-[var(--color-text-bright)]"
>
{switchAction}
</button>
</p>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style>
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentSlideIn {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
</style>

View File

@ -0,0 +1,112 @@
<script lang="ts">
let { value }: { value: string } = $props();
let copied = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
async function copy(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(value);
copied = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => (copied = false), 1800);
} catch {
// Clipboard API unavailable
}
}
</script>
<button
onclick={copy}
class="copy-btn"
class:copied
aria-label="Copy to clipboard"
>
<span class="copy-btn-inner">
{#if copied}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-icon">
<polyline points="20 6 9 17 4 12" />
</svg>
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="clipboard-icon">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
{/if}
</span>
</button>
<style>
.copy-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 4px;
border-radius: 4px;
color: var(--color-text-muted);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
}
.copy-btn:hover {
color: var(--color-text-secondary);
background: var(--color-bg-4);
border-color: var(--color-border);
}
.copy-btn:active {
transform: scale(0.92);
}
/* ── Copied state ── */
.copy-btn.copied {
opacity: 1;
color: var(--color-accent-bright);
background: rgba(94, 140, 88, 0.1);
border-color: rgba(94, 140, 88, 0.25);
}
.copy-btn-inner {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
/* ── Clipboard icon — subtle nudge on hover ── */
.clipboard-icon {
transition: transform 0.15s ease;
}
.copy-btn:hover .clipboard-icon {
transform: translate(-0.5px, -0.5px);
}
/* ── Check icon draw animation ── */
.check-icon {
animation: checkDraw 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
}
.check-icon polyline {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: drawCheck 0.3s cubic-bezier(0.25, 1, 0.5, 1) 0.05s forwards;
}
@keyframes drawCheck {
to { stroke-dashoffset: 0; }
}
@keyframes checkDraw {
0% { transform: scale(0.6); opacity: 0; }
50% { opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
</style>

View File

@ -1,23 +1,126 @@
<script lang="ts">
import { createCapsule, type Capsule, type CreateCapsuleParams } from '$lib/api/capsules';
import { createCapsule, listSnapshots, type Capsule, type CreateCapsuleParams, type Snapshot } from '$lib/api/capsules';
import { createAdminCapsule, listPlatformTemplates } from '$lib/api/admin-capsules';
type Props = {
open: boolean;
onclose: () => void;
oncreated?: (capsule: Capsule) => void;
/** 'team' = user-scoped templates (default), 'platform' = admin platform templates only */
templateSource?: 'team' | 'platform';
};
let { open, onclose, oncreated }: Props = $props();
let { open, onclose, oncreated, templateSource = 'team' }: Props = $props();
let createForm = $state<CreateCapsuleParams>({ template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 });
let creating = $state(false);
let createError = $state<string | null>(null);
// Template combobox state
let templates = $state<Snapshot[]>([]);
let templatesLoading = $state(false);
let templateQuery = $state('');
let comboOpen = $state(false);
let highlightIdx = $state(-1);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
let listEl = $state<HTMLUListElement | undefined>(undefined);
// Resolve selected template for type indicator + snapshot locking
let selectedTemplate = $derived(
templates.find((t) => t.name === createForm.template)
);
let selectedIsSnapshot = $derived(selectedTemplate?.type === 'snapshot');
let filtered = $derived.by(() => {
const q = templateQuery.toLowerCase();
if (!q) return templates;
return templates.filter((t) => t.name.toLowerCase().includes(q));
});
// Fetch templates when dialog opens
$effect(() => {
if (open && templates.length === 0 && !templatesLoading) {
templatesLoading = true;
const fetcher = templateSource === 'platform' ? listPlatformTemplates : listSnapshots;
fetcher().then((result) => {
if (result.ok) templates = result.data;
templatesLoading = false;
});
}
if (open) {
templateQuery = createForm.template ?? '';
}
});
function selectTemplate(t: Snapshot) {
createForm.template = t.name;
templateQuery = t.name;
// Pre-fill specs from the template if available
if (t.vcpus) createForm.vcpus = t.vcpus;
if (t.memory_mb) createForm.memory_mb = t.memory_mb;
comboOpen = false;
highlightIdx = -1;
}
function handleInputKeydown(e: KeyboardEvent) {
if (!comboOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
comboOpen = true;
highlightIdx = 0;
e.preventDefault();
return;
}
if (!comboOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
scrollToHighlighted();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollToHighlighted();
} else if (e.key === 'Enter' && highlightIdx >= 0 && highlightIdx < filtered.length) {
e.preventDefault();
selectTemplate(filtered[highlightIdx]);
} else if (e.key === 'Escape') {
comboOpen = false;
highlightIdx = -1;
}
}
function scrollToHighlighted() {
if (!listEl) return;
const item = listEl.children[highlightIdx] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}
function handleInputFocus() {
comboOpen = true;
highlightIdx = -1;
}
function handleInputBlur() {
// Delay to allow click on dropdown item to fire first
setTimeout(() => {
comboOpen = false;
// If the typed query matches an existing template, apply it
const match = templates.find((t) => t.name === templateQuery);
if (match) {
createForm.template = match.name;
} else {
// Allow free-form entry (user might know a template name not in the list)
createForm.template = templateQuery;
}
}, 150);
}
async function handleCreate() {
creating = true;
createError = null;
const result = await createCapsule(createForm);
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
const result = await creator(createForm);
if (result.ok) {
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
templateQuery = 'minimal';
oncreated?.(result.data);
onclose();
} else {
@ -36,8 +139,9 @@
onkeydown={(e) => { if (e.key === 'Escape' && !creating) onclose(); }}
></div>
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Launch Capsule</h2>
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
<div class="p-6">
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Launch Capsule</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Configure resources and launch. The VM will be ready in under a second.</p>
{#if createError}
@ -47,16 +151,101 @@
{/if}
<div class="mt-5 space-y-4">
<div>
<!-- Template combobox -->
<div class="relative">
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-template">Template</label>
<input
id="create-template"
type="text"
bind:value={createForm.template}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]"
placeholder="minimal"
/>
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Name of a snapshot or base image to boot from.</p>
<div class="relative">
{#if selectedTemplate}
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-1.5 w-1.5 rounded-full {selectedTemplate.type === 'snapshot' ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-blue)]'}"></span>
{/if}
<input
bind:this={inputEl}
id="create-template"
type="text"
role="combobox"
aria-expanded={comboOpen}
aria-autocomplete="list"
aria-controls="template-listbox"
autocomplete="off"
bind:value={templateQuery}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleInputKeydown}
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] py-2 pr-8 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60 {selectedTemplate ? 'pl-7' : 'pl-3'}"
placeholder="Search templates..."
/>
<!-- Chevron -->
<svg
class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-transform duration-150 {comboOpen ? 'rotate-180' : ''}"
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<!-- Dropdown -->
{#if comboOpen}
<ul
bind:this={listEl}
id="template-listbox"
role="listbox"
class="absolute z-10 mt-1 max-h-[200px] w-full overflow-y-auto rounded-[var(--radius-input)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)] py-1 shadow-lg"
style="animation: fadeUp 0.12s ease both"
>
{#if templatesLoading}
<li class="flex items-center gap-2 px-3 py-2.5 text-meta text-[var(--color-text-muted)]">
<svg class="animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
Loading templates...
</li>
{:else if filtered.length === 0}
<li class="px-3 py-2.5 text-meta text-[var(--color-text-muted)]">
{templateQuery ? 'No matching templates' : 'No templates available'}
</li>
{:else}
{#each filtered as t, i (t.name)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
role="option"
aria-selected={i === highlightIdx}
class="flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-colors duration-75
{i === highlightIdx
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-4)]'}
{createForm.template === t.name ? 'font-medium' : ''}"
onmousedown={(e) => { e.preventDefault(); selectTemplate(t); }}
onmouseenter={() => { highlightIdx = i; }}
>
<!-- Type badge -->
{#if t.type === 'snapshot'}
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-bright)]">
snap
</span>
{:else}
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-[var(--color-blue)]">
base
</span>
{/if}
<span class="truncate font-mono text-meta">{t.name}</span>
<!-- Specs hint -->
{#if t.vcpus && t.memory_mb}
<span class="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
{t.vcpus}v · {t.memory_mb}MB
</span>
{/if}
<!-- Selected check -->
{#if createForm.template === t.name}
<svg class="ml-auto shrink-0 text-[var(--color-accent)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</li>
{/each}
{/if}
</ul>
{/if}
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Snapshot or base image to boot from.</p>
</div>
<div class="grid grid-cols-2 gap-3">
@ -68,7 +257,8 @@
min="1"
max="8"
bind:value={createForm.vcpus}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
disabled={creating || selectedIsSnapshot}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
<div>
@ -80,7 +270,8 @@
max="8192"
step="128"
bind:value={createForm.memory_mb}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
disabled={creating || selectedIsSnapshot}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
</div>
@ -92,7 +283,8 @@
type="number"
min="0"
bind:value={createForm.timeout_sec}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
placeholder="0"
/>
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Seconds of inactivity before the capsule pauses. Set to 0 to keep it running indefinitely.</p>
@ -109,7 +301,7 @@
</button>
<button
onclick={handleCreate}
disabled={creating}
disabled={creating || !templateQuery.trim()}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if creating}
@ -122,6 +314,7 @@
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,87 @@
<script lang="ts">
import { destroyCapsule } from '$lib/api/capsules';
import type { ApiResult } from '$lib/api/client';
type Props = {
open: boolean;
capsuleId: string;
onclose: () => void;
ondestroyed?: () => void;
destroyFn?: (id: string) => Promise<ApiResult<void>>;
};
let { open, capsuleId, onclose, ondestroyed, destroyFn }: Props = $props();
let destroying = $state(false);
let error = $state<string | null>(null);
async function handleDestroy() {
destroying = true;
error = null;
const destroy = destroyFn ?? destroyCapsule;
const result = await destroy(capsuleId);
if (result.ok) {
error = null;
ondestroyed?.();
onclose();
} else {
error = result.error;
}
destroying = false;
}
function handleClose() {
if (!destroying) {
error = null;
onclose();
}
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={handleClose}
onkeydown={(e) => { if (e.key === 'Escape') handleClose(); }}
></div>
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
<div class="p-6">
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Destroy Capsule</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Terminate <span class="font-mono text-[var(--color-text-secondary)]">{capsuleId}</span> and destroy all data inside it. This cannot be undone.
</p>
{#if error}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{error}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={handleClose}
disabled={destroying}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDestroy}
disabled={destroying}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if destroying}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Destroying...
{:else}
Destroy
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,856 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import {
listDir,
readFile,
downloadFile,
isBinaryFile,
isFileTooLarge,
formatFileSize,
type FileEntry,
} from '$lib/api/files';
import { tokenize, type ThemedToken } from '$lib/highlight';
type Props = {
capsuleId: string;
isRunning: boolean;
apiBasePath?: string;
/** Hide the file preview pane when no file is selected */
compact?: boolean;
/** Show only the file tree, completely removing the preview panel */
treeOnly?: boolean;
};
let { capsuleId, isRunning, apiBasePath = '/api/v1/capsules', compact = false, treeOnly = false }: Props = $props();
// Directory navigation state
let currentPath = $state('~');
let entries = $state<FileEntry[]>([]);
let dirLoading = $state(false);
let dirError = $state<string | null>(null);
// File preview state
let selectedFile = $state<FileEntry | null>(null);
let fileContent = $state<string | null>(null);
let fileLoading = $state(false);
let fileError = $state<string | null>(null);
let downloading = $state(false);
// Syntax highlighting (lazy — loaded on first use)
let highlightedTokens = $state<ThemedToken[][] | null>(null);
// Request generation counters — discard stale responses from rapid clicks
let dirGeneration = 0;
let fileGeneration = 0;
// AbortController for in-flight file reads — aborted when the user
// selects a different file or the component is torn down.
let fileAbort: AbortController | null = null;
onDestroy(() => {
fileAbort?.abort();
});
const MAX_PREVIEW_LINES = 5000;
const MAX_HIGHLIGHT_LINES = 2000; // Don't tokenize huge files — diminishing returns
// Path input
let pathInput = $state('~');
let pathInputFocused = $state(false);
let pathInputEl = $state<HTMLInputElement | undefined>(undefined);
// Pre-computed preview lines — avoids re-splitting on every render
const previewLines = $derived.by(() => {
if (!fileContent) return { lines: [] as string[], truncated: false, totalLines: 0 };
const allLines = fileContent.split('\n');
const truncated = allLines.length > MAX_PREVIEW_LINES;
return {
lines: truncated ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines,
truncated,
totalLines: allLines.length,
};
});
// Sorted entries: directories first, then files, alphabetical within each group
const sortedEntries = $derived(
[...entries].sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
})
);
// Breadcrumb segments from currentPath
const breadcrumbs = $derived.by(() => {
const parts = currentPath.split('/').filter(Boolean);
const crumbs: { name: string; path: string }[] = [{ name: '/', path: '/' }];
for (let i = 0; i < parts.length; i++) {
crumbs.push({ name: parts[i], path: '/' + parts.slice(0, i + 1).join('/') });
}
return crumbs;
});
// Count of dirs vs files for the footer
const dirCount = $derived(entries.filter((e) => e.type === 'directory').length);
const fileCount = $derived(entries.filter((e) => e.type !== 'directory').length);
const canGoUp = $derived(currentPath !== '/' && currentPath.startsWith('/'));
// Only regular files can be downloaded — symlinks and other non-regular types
// may point to devices, sockets, or directories that can't be read as a file.
const isDownloadable = $derived(selectedFile?.type === 'file');
// Device files, pipes, sockets, etc. — can't be read or downloaded.
const isSpecialFile = $derived(selectedFile?.type === 'unknown');
async function navigateTo(path: string) {
// Abort any in-flight file read and invalidate stale generation so the
// abort error isn't surfaced in the UI.
fileAbort?.abort();
++fileGeneration;
currentPath = normalizePath(path);
pathInput = currentPath;
selectedFile = null;
fileContent = null;
fileError = null;
highlightedTokens = null;
await loadDir();
}
function normalizePath(p: string): string {
// Let envd handle ~ expansion — pass through as-is
if (p === '~' || p.startsWith('~/')) {
return p;
}
if (!p.startsWith('/')) {
// Relative path — resolve against current directory
p = currentPath.replace(/\/$/, '') + '/' + p;
}
// Collapse .. and .
const parts = p.split('/').filter(Boolean);
const resolved: string[] = [];
for (const part of parts) {
if (part === '..') resolved.pop();
else if (part !== '.') resolved.push(part);
}
return '/' + resolved.join('/');
}
/** Derive the parent directory from an entry's absolute path. */
function parentFromEntry(entryPath: string): string {
const lastSlash = entryPath.lastIndexOf('/');
if (lastSlash <= 0) return '/';
return entryPath.slice(0, lastSlash);
}
async function loadDir() {
if (!isRunning) return;
dirLoading = true;
dirError = null;
const gen = ++dirGeneration;
const result = await listDir(capsuleId, currentPath, 1, apiBasePath);
if (gen !== dirGeneration) return; // stale response
if (result.ok) {
entries = result.data.entries ?? [];
// Resolve actual path when envd expanded ~ or a relative path
if (!currentPath.startsWith('/') && entries.length > 0) {
currentPath = parentFromEntry(entries[0].path);
pathInput = currentPath;
}
} else {
dirError = result.error;
entries = [];
}
dirLoading = false;
}
async function selectFile(entry: FileEntry) {
if (entry.type === 'directory') {
await navigateTo(entry.path);
return;
}
// Abort any in-flight file read before starting a new one.
fileAbort?.abort();
selectedFile = entry;
fileContent = null;
fileError = null;
highlightedTokens = null;
// Non-regular files (devices, pipes, sockets) — nothing to read
if (entry.type === 'unknown') {
return;
}
// Check if we should preview or prompt download
if (isBinaryFile(entry.name) || isFileTooLarge(entry.size)) {
// Don't load content — the preview pane will show download prompt
return;
}
fileLoading = true;
const gen = ++fileGeneration;
const controller = new AbortController();
fileAbort = controller;
try {
const result = await readFile(capsuleId, entry.path, controller.signal, apiBasePath);
if (gen !== fileGeneration) return; // stale response — user clicked another file
if (result.ok) {
if (looksLikeBinary(result.data)) {
fileContent = null;
} else {
fileContent = result.data;
// Kick off highlighting in the background — preview shows plain text immediately.
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
: result.data;
tokenize(linesToHighlight, entry.name).then((tokens) => {
if (gen === fileGeneration) highlightedTokens = tokens;
});
}
} else if (result.error !== 'Request aborted') {
fileError = result.error;
}
} finally {
if (gen === fileGeneration) fileLoading = false;
}
}
function looksLikeBinary(text: string): boolean {
// Sample first 8KB for null bytes or high ratio of non-printable chars
const sample = text.slice(0, 8192);
let nonPrintable = 0;
for (let i = 0; i < sample.length; i++) {
const code = sample.charCodeAt(i);
if (code === 0) return true;
if (code < 32 && code !== 9 && code !== 10 && code !== 13) nonPrintable++;
}
return sample.length > 0 && nonPrintable / sample.length > 0.1;
}
async function handleDownload() {
if (!selectedFile || downloading || selectedFile.type !== 'file') return;
downloading = true;
try {
await downloadFile(capsuleId, selectedFile.path, selectedFile.name, undefined, apiBasePath);
} catch {
fileError = 'Download failed';
}
downloading = false;
}
function handlePathSubmit(e: SubmitEvent) {
e.preventDefault();
const target = pathInput.trim();
if (!target) return;
const resolved = normalizePath(target);
navigateOrOpenFile(resolved);
}
async function navigateOrOpenFile(path: string) {
// First try as directory
const dirResult = await listDir(capsuleId, path, 1, apiBasePath);
if (dirResult.ok) {
// Resolve actual path from entries (handles ~ expansion by envd)
const resolvedEntries = dirResult.data.entries ?? [];
let resolvedPath = path;
if (resolvedEntries.length > 0) {
// Derive parent dir from first entry's absolute path
const firstPath = resolvedEntries[0].path;
const lastSlash = firstPath.lastIndexOf('/');
if (lastSlash >= 0) {
resolvedPath = lastSlash === 0 ? '/' : firstPath.slice(0, lastSlash);
}
}
currentPath = resolvedPath;
pathInput = resolvedPath;
entries = resolvedEntries;
selectedFile = null;
fileContent = null;
fileError = null;
return;
}
// If directory listing failed, try reading as a file
// We need the parent dir to get the file entry info
const lastSlash = path.lastIndexOf('/');
const parentPath = lastSlash <= 0 ? '/' : path.slice(0, lastSlash);
const fileName = path.slice(lastSlash + 1);
// Navigate to parent directory
currentPath = parentPath;
pathInput = parentPath;
const parentResult = await listDir(capsuleId, parentPath, 1, apiBasePath);
if (parentResult.ok) {
entries = parentResult.data.entries ?? [];
// Find the file in parent listing
const found = entries.find((e) => e.name === fileName);
if (found && found.type !== 'directory') {
await selectFile(found);
} else {
dirError = `Not found: ${path}`;
}
} else {
dirError = parentResult.error;
entries = [];
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
(e.target as HTMLInputElement)?.blur();
}
}
function fileIcon(entry: FileEntry): string {
if (entry.type === 'directory') return 'dir';
if (entry.type === 'symlink') return 'link';
if (entry.type === 'unknown') return 'special';
return 'file';
}
// File extension for subtle coloring
function fileExt(name: string): string {
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Extension → color mapping for file icons and badges
function extColor(name: string): string {
const ext = fileExt(name);
switch (ext) {
case 'go': case 'mod': case 'sum':
return '#5a9fd4'; // blue — Go
case 'py': case 'pyi': case 'pyx':
return '#d4a73c'; // amber — Python
case 'js': case 'mjs': case 'cjs':
return '#d4a73c'; // amber — JavaScript
case 'ts': case 'mts': case 'cts': case 'tsx': case 'jsx':
return '#5a9fd4'; // blue — TypeScript/React
case 'rs':
return '#cf8172'; // red — Rust
case 'sh': case 'bash': case 'zsh': case 'fish':
return '#5e8c58'; // accent — shell
case 'json': case 'yaml': case 'yml': case 'toml': case 'ini': case 'env':
return '#8b7ec8'; // purple — config
case 'md': case 'mdx': case 'txt': case 'rst':
return 'var(--color-text-secondary)'; // neutral — docs
case 'sql':
return '#5a9fd4'; // blue — SQL
case 'proto':
return '#5e8c58'; // accent — protobuf
case 'svelte': case 'vue':
return '#cf8172'; // red — Svelte/Vue
case 'css': case 'scss': case 'less':
return '#5a9fd4'; // blue — styles
case 'html': case 'htm':
return '#cf8172'; // red — HTML
case 'dockerfile': case 'makefile':
return '#5e8c58'; // accent — build
default:
return 'var(--color-text-muted)';
}
}
// Descriptive label for file type badge in preview header
function extLabel(name: string): string {
const ext = fileExt(name);
const lower = name.toLowerCase();
if (lower === 'makefile') return 'Make';
if (lower === 'dockerfile') return 'Docker';
switch (ext) {
case 'go': return 'Go';
case 'py': return 'Python';
case 'js': case 'mjs': case 'cjs': return 'JS';
case 'ts': case 'mts': case 'cts': return 'TS';
case 'tsx': return 'TSX';
case 'jsx': return 'JSX';
case 'rs': return 'Rust';
case 'sh': case 'bash': return 'Shell';
case 'json': return 'JSON';
case 'yaml': case 'yml': return 'YAML';
case 'toml': return 'TOML';
case 'sql': return 'SQL';
case 'proto': return 'Proto';
case 'svelte': return 'Svelte';
case 'css': return 'CSS';
case 'html': case 'htm': return 'HTML';
case 'md': case 'mdx': return 'Markdown';
default: return ext ? ext.toUpperCase() : '';
}
}
// Load initial directory on mount, falling back to / if home can't be resolved
let hasInitiallyLoaded = false;
$effect(() => {
if (isRunning && !hasInitiallyLoaded) {
hasInitiallyLoaded = true;
loadDir().then(() => {
if (!currentPath.startsWith('/')) {
currentPath = '/';
pathInput = '/';
if (dirError) loadDir();
}
});
}
});
</script>
<style>
.file-row {
transition: background-color 0.1s ease;
}
.file-row:hover {
background-color: var(--color-bg-3);
}
.file-row.active {
background-color: var(--color-accent-glow);
border-left: 3px solid var(--color-accent);
box-shadow: inset 0 0 20px rgba(94, 140, 88, 0.06);
}
.file-row:not(.active) {
border-left: 3px solid transparent;
}
.preview-code {
tab-size: 4;
-moz-tab-size: 4;
}
/* Let the browser skip rendering off-screen lines in long files */
.code-line {
content-visibility: auto;
contain-intrinsic-size: auto 1.65rem;
}
/* Staggered row entrance */
@keyframes rowSlideIn {
from { opacity: 0; transform: translateX(-4px); }
to { opacity: 1; transform: translateX(0); }
}
.row-enter {
animation: rowSlideIn 0.15s ease both;
}
/* Line highlight on hover */
.code-line:hover .line-content {
background-color: var(--color-bg-3);
}
.code-line:hover .line-num {
color: var(--color-text-tertiary);
}
</style>
{#if !isRunning}
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<div class="flex flex-col gap-1">
<span class="text-ui font-medium text-[var(--color-text-secondary)]">Capsule not running</span>
<span class="text-meta text-[var(--color-text-muted)]">Start or resume the capsule to browse files</span>
</div>
</div>
</div>
{:else}
<div class="flex flex-1 min-h-0">
<!-- Left panel: File tree -->
<div class="flex shrink-0 flex-col bg-[var(--color-bg-2)] {(treeOnly || (compact && !selectedFile)) ? 'flex-1' : compact ? 'w-[28.57%] border-r border-[var(--color-border)]' : 'w-[380px] border-r border-[var(--color-border)]'}"
>
<!-- Path input -->
<form onsubmit={handlePathSubmit} class="border-b border-[var(--color-border)] px-4 py-3">
<div class="flex items-center gap-2 rounded-[var(--radius-input)] border px-3 py-1.5 transition-colors duration-150
{pathInputFocused
? 'border-[var(--color-accent)]/50 bg-[var(--color-bg-0)]'
: 'border-[var(--color-border)] bg-[var(--color-bg-1)]'}">
<!-- Terminal prompt icon -->
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)] select-none" aria-hidden="true">
$
</span>
<input
type="text"
bind:this={pathInputEl}
bind:value={pathInput}
onfocus={() => (pathInputFocused = true)}
onblur={() => (pathInputFocused = false)}
onkeydown={handleKeydown}
placeholder="/home/user or ~/file.txt"
spellcheck="false"
autocomplete="off"
class="flex-1 bg-transparent font-mono text-meta text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
<button
type="submit"
class="shrink-0 flex items-center gap-1 rounded-[var(--radius-button)] px-2 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-accent-glow-mid)] hover:text-[var(--color-accent-mid)]"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
Go
</button>
</div>
</form>
<!-- Breadcrumbs -->
<div class="flex items-center gap-0.5 border-b border-[var(--color-border)] px-2 py-2 overflow-x-auto">
<!-- Up button -->
<button
onclick={() => navigateTo(currentPath + '/..')}
disabled={!canGoUp}
title="Go to parent directory"
class="shrink-0 flex items-center justify-center rounded-[3px] w-6 h-6 transition-colors
{canGoUp
? 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]'
: 'text-[var(--color-text-muted)] opacity-30 cursor-not-allowed'}"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<span class="w-px h-4 bg-[var(--color-border)] shrink-0 mx-1"></span>
{#each breadcrumbs as crumb, i}
{#if i > 0}
<svg class="shrink-0 text-[var(--color-text-muted)]" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
{/if}
<button
onclick={() => navigateTo(crumb.path)}
class="shrink-0 rounded-[3px] px-1.5 py-0.5 font-mono text-label transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]
{i === breadcrumbs.length - 1
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)]'}"
>
{#if i === 0}
<!-- Root icon -->
<svg class="inline -mt-px" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
{:else}
{crumb.name}
{/if}
</button>
{/each}
</div>
<!-- File list -->
<div class="flex-1 overflow-y-auto">
{#if dirLoading}
<div class="flex items-center justify-center py-12">
<div class="flex items-center gap-2 text-meta text-[var(--color-text-secondary)]">
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading...
</div>
</div>
{:else if dirError}
<div class="px-4 py-4">
<div class="flex items-start gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/6 px-3.5 py-3">
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="text-meta text-[var(--color-red)]">{dirError}</span>
</div>
</div>
{:else if entries.length === 0}
<div class="flex flex-col items-center justify-center py-16 gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<span class="text-meta text-[var(--color-text-muted)]">Empty directory</span>
</div>
{:else}
{#each sortedEntries as entry, idx (entry.path)}
<button
onclick={() => selectFile(entry)}
class="file-row flex w-full items-center gap-3 px-4 py-[7px] text-left
{selectedFile?.path === entry.path ? 'active' : ''}
{idx < 30 ? 'row-enter' : ''}"
style={idx < 30 ? `animation-delay: ${idx * 12}ms` : undefined}
>
<!-- Icon -->
{#if fileIcon(entry) === 'dir'}
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
{:else if fileIcon(entry) === 'link'}
<svg class="shrink-0 text-[var(--color-blue)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{:else if fileIcon(entry) === 'special'}
<svg class="shrink-0 text-[var(--color-text-muted)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
{:else}
<svg class="shrink-0" style="color: {extColor(entry.name)}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{/if}
<!-- Name + metadata -->
<div class="flex flex-1 items-center gap-2 overflow-hidden">
<span class="truncate font-mono text-meta
{entry.type === 'directory'
? 'text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)]'}">
{entry.name}
</span>
{#if entry.type === 'symlink' && entry.symlink_target}
<span class="truncate font-mono text-badge text-[var(--color-text-muted)]">
&rarr; {entry.symlink_target}
</span>
{/if}
</div>
<!-- Size + extension hint (files only) -->
{#if entry.type === 'file'}
{#if fileExt(entry.name)}
<span class="shrink-0 font-mono text-[9px] uppercase tracking-[0.05em]" style="color: {extColor(entry.name)}; opacity: 0.7">
{fileExt(entry.name)}
</span>
{/if}
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)]">
{formatFileSize(entry.size)}
</span>
{/if}
<!-- Permissions -->
<span class="hidden shrink-0 font-mono text-badge text-[var(--color-text-muted)] xl:inline">
{entry.permissions}
</span>
</button>
{/each}
{/if}
</div>
<!-- Footer: entry count -->
{#if !dirLoading && !dirError && entries.length > 0}
<div class="border-t border-[var(--color-border)] px-4 py-2 flex items-center gap-3">
{#if dirCount > 0}
<span class="font-mono text-badge text-[var(--color-text-muted)]">
{dirCount} dir{dirCount !== 1 ? 's' : ''}
</span>
{/if}
{#if fileCount > 0}
<span class="font-mono text-badge text-[var(--color-text-muted)]">
{fileCount} file{fileCount !== 1 ? 's' : ''}
</span>
{/if}
</div>
{/if}
</div>
<!-- Right panel: File preview -->
{#if !treeOnly && (!compact || selectedFile)}
<div class="flex flex-1 flex-col min-w-0 bg-[var(--color-bg-1)]">
{#if !selectedFile}
<!-- Empty state -->
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-3 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</div>
<div class="flex flex-col gap-1">
<span class="text-ui text-[var(--color-text-secondary)]">Select a file to preview</span>
<span class="text-meta text-[var(--color-text-muted)]">Click a file in the tree or type a path above</span>
</div>
</div>
</div>
{:else}
<!-- File header -->
<div class="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-2)] px-5 py-2.5">
<div class="flex items-center gap-2.5 overflow-hidden">
{#if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size)}
<svg class="shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{:else}
<svg class="shrink-0" style="color: {extColor(selectedFile.name)}" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{/if}
<span class="truncate font-mono text-meta text-[var(--color-text-primary)]">{selectedFile.path}</span>
{#if extLabel(selectedFile.name)}
<span
class="shrink-0 rounded-[3px] border px-1.5 py-0.5 font-mono text-badge font-semibold uppercase tracking-[0.03em]"
style="color: {extColor(selectedFile.name)}; border-color: color-mix(in srgb, {extColor(selectedFile.name)} 25%, transparent); background: color-mix(in srgb, {extColor(selectedFile.name)} 8%, transparent)"
>
{extLabel(selectedFile.name)}
</span>
{/if}
</div>
<div class="flex items-center gap-3 shrink-0 ml-4">
<span class="font-mono text-badge text-[var(--color-text-muted)]">{formatFileSize(selectedFile.size)}</span>
<button
onclick={handleDownload}
disabled={downloading || !isDownloadable}
title={isDownloadable ? 'Download file' : 'Not a regular file — download unavailable'}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if downloading}
<svg class="animate-spin" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{:else}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{/if}
Download
</button>
</div>
</div>
<!-- File content -->
<div class="flex-1 overflow-auto">
{#if fileLoading}
<div class="flex items-center justify-center py-16">
<div class="flex items-center gap-2 text-meta text-[var(--color-text-secondary)]">
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Reading file...
</div>
</div>
{:else if fileError}
<div class="px-5 py-5">
<div class="flex items-start gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/6 px-3.5 py-3">
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="text-meta text-[var(--color-red)]">{fileError}</span>
</div>
</div>
{:else if isSpecialFile}
<!-- Device file, pipe, socket, etc. — can't read or download -->
<div class="flex flex-1 items-center justify-center py-20">
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
<svg class="text-[var(--color-text-muted)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-ui font-medium text-[var(--color-text-primary)]">Not a regular file</span>
<span class="text-meta text-[var(--color-text-tertiary)]">
<code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[var(--color-text-secondary)]">{selectedFile.name}</code>
is a device, socket, or pipe
</span>
<span class="mt-1 text-meta text-[var(--color-text-muted)]">
These file types can't be read or downloaded.
</span>
</div>
</div>
</div>
{:else if !isDownloadable}
<!-- Symlink — no preview or download -->
<div class="flex flex-1 items-center justify-center py-20">
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
<svg class="text-[var(--color-blue)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-ui font-medium text-[var(--color-text-primary)]">Symlink</span>
{#if selectedFile.symlink_target}
<span class="text-meta text-[var(--color-text-tertiary)]">
Points to <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[var(--color-text-secondary)]">{selectedFile.symlink_target}</code>
</span>
{/if}
<span class="mt-1 text-meta text-[var(--color-text-muted)]">
Open the target path to view or download its contents.
</span>
</div>
</div>
</div>
{:else if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size) || (selectedFile && fileContent === null && !fileLoading)}
<!-- Binary / too large / unreadable — download prompt -->
<div class="flex flex-1 items-center justify-center py-20">
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
{#if isFileTooLarge(selectedFile.size)}
<svg class="text-[var(--color-amber)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
{:else}
<svg class="text-[var(--color-text-muted)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
{/if}
</div>
<div class="flex flex-col gap-1.5">
{#if isFileTooLarge(selectedFile.size)}
<span class="text-ui font-medium text-[var(--color-text-primary)]">File too large to preview</span>
<span class="text-meta text-[var(--color-text-tertiary)]">
{formatFileSize(selectedFile.size)} exceeds the 10 MB preview limit
</span>
{:else}
<span class="text-ui font-medium text-[var(--color-text-primary)]">Binary file</span>
<span class="text-meta text-[var(--color-text-tertiary)]">
This file type can't be displayed as text
</span>
{/if}
</div>
<button
onclick={handleDownload}
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-4 py-2 text-meta font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download file
</button>
</div>
</div>
{:else if fileContent !== null}
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
<div style="animation: fadeUp 0.15s ease both">
<pre class="preview-code p-0 m-0"><code class="block">{#each previewLines.lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)]">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem]">{#if highlightedTokens && highlightedTokens[i]}{#each highlightedTokens[i] as token}<span style="color: {token.color ?? 'var(--color-text-secondary)'}">{token.content}</span>{/each}{:else}<span class="text-[var(--color-text-secondary)]">{line || ' '}</span>{/if}</span></div>{/each}</code></pre>
</div>
{#if previewLines.truncated}
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
<span class="text-meta text-[var(--color-text-tertiary)]">
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {previewLines.totalLines.toLocaleString()} lines
</span>
<button
onclick={handleDownload}
class="font-mono text-meta text-[var(--color-accent-mid)] transition-colors hover:text-[var(--color-accent-bright)]"
>Download full file</button>
</div>
{/if}
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@ -0,0 +1,350 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import {
fetchCapsuleMetrics,
METRIC_RANGES,
METRIC_POLL_INTERVALS,
type MetricRange,
type MetricPoint
} from '$lib/api/metrics';
type Props = {
capsuleId: string;
/** Whether the capsule is in a state that supports metrics */
available: boolean;
/** Initial range selection */
initialRange?: MetricRange;
/** API base path for fetching metrics */
apiBasePath?: string;
/** Layout: 'full' shows padded cards with gap, 'compact' shows borderless stacked charts */
layout?: 'full' | 'compact';
};
let { capsuleId, available, initialRange = '10m' as MetricRange, apiBasePath = '/api/v1/capsules', layout = 'full' }: Props = $props();
// svelte-ignore state_referenced_locally
let range = $state<MetricRange>(initialRange);
let points = $state<MetricPoint[]>([]);
let metricsLoading = $state(true);
let metricsError = $state<string | null>(null);
let canvasCpu = $state<HTMLCanvasElement | undefined>(undefined);
let canvasRam = $state<HTMLCanvasElement | undefined>(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartCpu: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartRam: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ChartJS = $state<any>(null);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let lastDataKey = '';
let visibilityHandler: (() => void) | null = null;
const latestCpu = $derived<number | null>(
points.length > 0 ? points[points.length - 1].cpu_pct : null
);
const latestRamMB = $derived<number | null>(
points.length > 0 ? points[points.length - 1].mem_bytes / 1_048_576 : null
);
async function loadMetrics() {
if (!available) return;
const result = await fetchCapsuleMetrics(capsuleId, range, apiBasePath);
if (result.ok) {
points = result.data.points;
metricsError = null;
} else {
metricsError = result.error;
}
metricsLoading = false;
updateCharts();
}
function smooth(data: number[], window: number): number[] {
if (window <= 1) return data;
const out: number[] = [];
for (let i = 0; i < data.length; i++) {
const start = Math.max(0, i - Math.floor(window / 2));
const end = Math.min(data.length, i + Math.ceil(window / 2));
let sum = 0;
for (let j = start; j < end; j++) sum += data[j];
out.push(+(sum / (end - start)).toFixed(2));
}
return out;
}
function smoothWindow(count: number): number {
if (count < 60) return 1;
if (count < 200) return 3;
if (count < 600) return 5;
return 9;
}
function updateCharts() {
if (!points.length) return;
const key = `${points.length}:${points.at(-1)?.timestamp_unix ?? ''}`;
if (key === lastDataKey) return;
lastDataKey = key;
const labels = points.map((p) => {
const d = new Date(p.timestamp_unix * 1000);
if (range === '5m' || range === '10m') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
const w = smoothWindow(points.length);
if (chartCpu) {
chartCpu.data.labels = labels;
chartCpu.data.datasets[0].data = smooth(points.map((p) => +p.cpu_pct.toFixed(2)), w);
chartCpu.update();
}
if (chartRam) {
chartRam.data.labels = labels;
chartRam.data.datasets[0].data = smooth(points.map((p) => +(p.mem_bytes / 1_048_576).toFixed(1)), w);
chartRam.update();
}
}
function setRange(r: MetricRange) {
range = r;
lastDataKey = '';
metricsLoading = true;
restartPolling();
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
function restartPolling() {
stopPolling();
loadMetrics();
pollInterval = setInterval(loadMetrics, METRIC_POLL_INTERVALS[range]);
}
const C_BLUE = '#5a9fd4';
const C_BLUE_FILL = 'rgba(90,159,212,0.11)';
const C_AMBER = '#d4a73c';
const C_AMBER_FILL = 'rgba(212,167,60,0.11)';
const C_GRID = 'rgba(255,255,255,0.05)';
const C_TICK = '#635f5c';
const FONT_MONO = "'JetBrains Mono', monospace";
const BASE_CHART_OPTIONS = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#111412',
borderColor: '#1f2321',
borderWidth: 1,
titleColor: '#454340',
bodyColor: '#d4cfc8',
titleFont: { family: FONT_MONO, size: 10 },
bodyFont: { family: FONT_MONO, size: 11 },
padding: 10,
caretSize: 4,
},
},
scales: {
x: {
grid: { color: C_GRID },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, maxTicksLimit: 8, maxRotation: 0 },
border: { color: C_GRID },
},
y: {
grid: { color: C_GRID },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 } },
border: { color: C_GRID },
beginAtZero: true,
},
},
};
function initCharts() {
if (!ChartJS || !canvasCpu || !canvasRam) return;
chartCpu?.destroy();
chartRam?.destroy();
chartCpu = new ChartJS(canvasCpu, {
type: 'line',
data: {
labels: [],
datasets: [{
data: [], borderColor: C_BLUE, backgroundColor: C_BLUE_FILL,
borderWidth: 2, fill: true, tension: 0, pointRadius: 0,
pointHoverRadius: 4, pointHoverBackgroundColor: C_BLUE,
}],
},
options: {
...BASE_CHART_OPTIONS,
plugins: { ...BASE_CHART_OPTIONS.plugins, tooltip: { ...BASE_CHART_OPTIONS.plugins.tooltip,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callbacks: { label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)}%` },
}},
scales: { ...BASE_CHART_OPTIONS.scales, y: { ...BASE_CHART_OPTIONS.scales.y,
ticks: { ...BASE_CHART_OPTIONS.scales.y.ticks, callback: (v: string | number) => `${+v}%` },
}},
},
});
chartRam = new ChartJS(canvasRam, {
type: 'line',
data: {
labels: [],
datasets: [{
data: [], borderColor: C_AMBER, backgroundColor: C_AMBER_FILL,
borderWidth: 2, fill: true, tension: 0, pointRadius: 0,
pointHoverRadius: 4, pointHoverBackgroundColor: C_AMBER,
}],
},
options: {
...BASE_CHART_OPTIONS,
plugins: { ...BASE_CHART_OPTIONS.plugins, tooltip: { ...BASE_CHART_OPTIONS.plugins.tooltip,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callbacks: { label: (ctx: any) => ` ${ctx.parsed.y.toFixed(0)} MB` },
}},
scales: { ...BASE_CHART_OPTIONS.scales, y: { ...BASE_CHART_OPTIONS.scales.y,
ticks: { ...BASE_CHART_OPTIONS.scales.y.ticks, callback: (v: string | number) => `${+v} MB` },
}},
},
});
updateCharts();
}
$effect(() => {
if (!ChartJS || !available) return;
tick().then(() => {
if (canvasCpu && canvasRam) {
initCharts();
restartPolling();
}
});
return () => {
stopPolling();
chartCpu?.destroy(); chartCpu = null;
chartRam?.destroy(); chartRam = null;
};
});
onMount(async () => {
if (!available) return;
const mod = await import('chart.js/auto');
ChartJS = mod.Chart;
visibilityHandler = () => {
if (document.hidden) {
stopPolling();
} else if (available) {
restartPolling();
}
};
document.addEventListener('visibilitychange', visibilityHandler);
});
onDestroy(() => {
stopPolling();
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler);
chartCpu?.destroy();
chartRam?.destroy();
});
</script>
<style>
.metric-val {
transition: color 0.3s ease;
}
</style>
<div class="flex flex-1 flex-col min-h-0">
<!-- Controls row -->
<div class="flex shrink-0 items-center justify-between {layout === 'full' ? 'px-0 pb-5' : 'border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-5 py-2'}">
{#if layout === 'full'}
{#if !metricsLoading}
<span class="flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-2 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-accent-mid)]">
<span class="h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
Live
</span>
{:else}
<div></div>
{/if}
{:else}
<div></div>
{/if}
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
{#each METRIC_RANGES as r, i}
<button
onclick={() => setRange(r)}
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
{range === r
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)]'}
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
>
{r}
</button>
{/each}
</div>
</div>
{#if metricsError}
<div class="flex shrink-0 items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-3 {layout === 'full' ? 'mb-5' : 'mx-5 my-3'}">
<svg class="shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="text-ui text-[var(--color-red)]">Could not load metrics: {metricsError}. Will retry automatically.</span>
</div>
{/if}
<!-- Charts — stacked, each grows to fill half -->
<div class="flex flex-1 flex-col min-h-0 {layout === 'full' ? 'gap-5' : 'divide-y divide-[var(--color-border)]'}">
<!-- CPU Usage -->
<div class="flex flex-1 flex-col min-h-0 {layout === 'full' ? 'rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]' : 'bg-[var(--color-bg-1)]'}">
<div class="flex shrink-0 items-center justify-between {layout === 'full' ? 'border-b border-[var(--color-border)] px-6 py-4' : 'px-5 py-2'}">
<div class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" style="background: {C_BLUE}"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU Usage</span>
</div>
{#if latestCpu !== null}
<div class="flex items-baseline gap-1">
<span class="metric-val font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestCpu.toFixed(1)}</span>
<span class="font-mono {layout === 'full' ? 'text-label' : 'text-badge'} text-[var(--color-text-muted)]">%</span>
</div>
{:else if metricsLoading}
<span class="font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none text-[var(--color-text-muted)]"></span>
{/if}
</div>
<div class="relative flex-1 min-h-0 {layout === 'full' ? 'min-h-[180px] px-5 pb-5 pt-3' : 'px-4 pb-3 pt-1'}">
<canvas bind:this={canvasCpu}></canvas>
</div>
</div>
<!-- RAM Usage -->
<div class="flex flex-1 flex-col min-h-0 {layout === 'full' ? 'rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]' : 'bg-[var(--color-bg-1)]'}">
<div class="flex shrink-0 items-center justify-between {layout === 'full' ? 'border-b border-[var(--color-border)] px-6 py-4' : 'px-5 py-2'}">
<div class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" style="background: {C_AMBER}"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM Usage</span>
</div>
{#if latestRamMB !== null}
<div class="flex items-baseline gap-1">
<span class="metric-val font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestRamMB.toFixed(0)}</span>
<span class="font-mono {layout === 'full' ? 'text-label' : 'text-badge'} text-[var(--color-text-muted)]">MB</span>
</div>
{:else if metricsLoading}
<span class="font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none text-[var(--color-text-muted)]"></span>
{/if}
</div>
<div class="relative flex-1 min-h-0 {layout === 'full' ? 'min-h-[180px] px-5 pb-5 pt-3' : 'px-4 pb-3 pt-1'}">
<canvas bind:this={canvasRam}></canvas>
</div>
</div>
</div>
</div>

View File

@ -49,7 +49,7 @@
const platformItems: NavItem[] = [
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' },
{ label: 'Templates', icon: IconBox, href: '/dashboard/templates' },
{ label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }
];
@ -280,13 +280,21 @@
<IconBell size={16} class="shrink-0" />
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
</div>
<div
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Settings (coming soon)' : 'Coming soon'}
<a
href="/dashboard/settings"
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'} {isActive('/dashboard/settings') ? (collapsed ? 'bg-[var(--color-accent-glow-mid)]' : 'bg-[var(--color-accent)]/[0.12]') : ''}"
title={collapsed ? 'Settings' : undefined}
>
<IconSettings size={16} class="shrink-0" />
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
</div>
{#if isActive('/dashboard/settings') && !collapsed}
<div class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<IconSettings size={16} class="shrink-0 {isActive('/dashboard/settings') ? 'text-[var(--color-accent-bright)]' : 'opacity-50 transition-opacity duration-150 group-hover:opacity-100'}" />
{#if !collapsed}
<span class="text-ui transition-colors duration-150 {isActive('/dashboard/settings') ? 'font-semibold text-[var(--color-accent-bright)]' : 'text-[var(--color-text-primary)] group-hover:text-[var(--color-text-bright)]'}">
Settings
</span>
{/if}
</a>
</div>
<!-- User footer -->
@ -402,7 +410,7 @@
class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">
Create Team
</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">

View File

@ -0,0 +1,130 @@
<script lang="ts">
import { createSnapshot } from '$lib/api/capsules';
type Props = {
open: boolean;
capsuleId: string;
pauseFirst?: boolean;
onclose: () => void;
onsnapshot?: () => void;
};
let { open, capsuleId, pauseFirst = false, onclose, onsnapshot }: Props = $props();
let snapshotName = $state('');
let snapshotting = $state(false);
let error = $state<string | null>(null);
function reset() {
snapshotName = '';
error = null;
}
async function handleConfirm() {
snapshotting = true;
error = null;
const result = await createSnapshot(capsuleId, snapshotName.trim() || undefined);
if (result.ok) {
reset();
onsnapshot?.();
onclose();
} else {
error = result.error;
}
snapshotting = false;
}
function handleClose() {
if (!snapshotting) {
reset();
onclose();
}
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={handleClose}
onkeydown={(e) => { if (e.key === 'Escape') handleClose(); }}
></div>
<div class="relative w-full max-w-[420px] overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
<div class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-5">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-input)] bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-[0_0_12px_var(--color-accent-glow)]">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 4h-5L7 7H2v13a2 2 0 002 2h16a2 2 0 002-2V7h-5l-2.5-3z" />
<circle cx="12" cy="15" r="3" />
</svg>
</div>
<div>
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Capture snapshot</h2>
<p class="mt-0.5 text-meta text-[var(--color-text-muted)] font-mono">{capsuleId}</p>
</div>
</div>
<div class="px-6 pt-5 pb-6 space-y-4">
{#if pauseFirst}
<div class="flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-3 py-2.5">
<svg class="mt-px shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p class="text-meta text-[var(--color-amber)] leading-relaxed">This capsule will be <strong class="font-semibold">paused first</strong>, then its full state (memory + disk) will be captured.</p>
</div>
{:else}
<p class="text-ui text-[var(--color-text-tertiary)]">The capsule's current state (memory + disk) will be captured and stored as a reusable snapshot.</p>
{/if}
{#if error}
<div class="rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{error}
</div>
{/if}
<div>
<div class="mb-1.5 flex items-baseline justify-between">
<label class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="snapshot-name">Snapshot name</label>
<span class="text-meta text-[var(--color-text-muted)]">optional</span>
</div>
<input
id="snapshot-name"
type="text"
bind:value={snapshotName}
disabled={snapshotting}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-50"
placeholder="e.g. after-apt-install, pre-migration"
onkeydown={(e) => { if (e.key === 'Enter' && !snapshotting) handleConfirm(); }}
/>
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Leave blank to use an auto-generated name.</p>
</div>
<div class="flex justify-end gap-3 pt-1">
<button
onclick={handleClose}
disabled={snapshotting}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleConfirm}
disabled={snapshotting}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if snapshotting}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Capturing...
{:else}
Capture snapshot
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -26,6 +26,8 @@
let chartRam: any = null;
let pollInterval: ReturnType<typeof setInterval> | null = null;
let lastDataKey = ''; // cheap fingerprint to skip redundant chart redraws
let visibilityHandler: (() => void) | null = null;
async function load() {
const result = await fetchStats(range);
@ -43,6 +45,10 @@
function updateCharts() {
if (!stats) return;
// Skip redraw if data hasn't changed (same length + same last label).
const key = `${stats.series.labels.length}:${stats.series.labels.at(-1) ?? ''}`;
if (key === lastDataKey) return;
lastDataKey = key;
// Use Array.from to pass plain JS arrays to Chart.js — Svelte 5 $state
// wraps arrays in reactive proxies which Chart.js can't iterate reliably.
const labels = formatLabels(Array.from(stats.series.labels), range);
@ -77,14 +83,19 @@
});
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
function restartPolling() {
if (pollInterval) clearInterval(pollInterval);
stopPolling();
load();
pollInterval = setInterval(load, POLL_INTERVALS[range]);
}
function setRange(r: TimeRange) {
range = r;
lastDataKey = ''; // force chart redraw on range switch
goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true });
restartPolling();
}
@ -185,7 +196,7 @@
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: number) => `${v}`,
callback: (v: string | number) => `${v}`,
},
},
},
@ -215,7 +226,8 @@
tooltip: {
...BASE_CHART_OPTIONS.plugins.tooltip,
callbacks: {
label: (ctx: { parsed: { y: number } }) => ` ${ctx.parsed.y.toFixed(1)} GB`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)} GB`,
},
},
},
@ -225,7 +237,7 @@
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: number) => `${(+v).toFixed(1)} GB`,
callback: (v: string | number) => `${(+v).toFixed(1)} GB`,
},
},
},
@ -236,10 +248,21 @@
updateCharts();
restartPolling();
// Pause polling when the browser tab is hidden to save bandwidth/CPU.
visibilityHandler = () => {
if (document.hidden) {
stopPolling();
} else {
restartPolling();
}
};
document.addEventListener('visibilitychange', visibilityHandler);
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
stopPolling();
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler);
chartRunning?.destroy();
chartCpu?.destroy();
chartRam?.destroy();
@ -312,7 +335,7 @@
</div>
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
<div class="mt-2 font-serif text-[1.714rem] leading-none text-[var(--color-text-secondary)]">
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
</div>
</div>
@ -334,7 +357,7 @@
</div>
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
<div class="mt-2 font-serif text-[1.714rem] leading-none text-[var(--color-text-secondary)]">
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
</div>
</div>
@ -356,7 +379,7 @@
</div>
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
<div class="mt-2 font-serif text-[1.714rem] leading-none text-[var(--color-text-secondary)]">
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
</div>
</div>

View File

@ -0,0 +1,602 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { auth } from '$lib/auth.svelte';
type Props = {
capsuleId: string;
isRunning: boolean;
visible?: boolean;
apiBasePath?: string;
};
let { capsuleId, isRunning, visible = true, apiBasePath = '/api/v1/capsules' }: Props = $props();
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
type SessionDisplay = {
id: number;
state: ConnectionState;
errorMessage: string | null;
ptyTag: string | null;
ptyPid: number | null;
};
type SessionInternal = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
term: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fitAddon: any;
ws: WebSocket | null;
resizeObserver: ResizeObserver | null;
fitDebounce: ReturnType<typeof setTimeout> | null;
inputFlushTimer: ReturnType<typeof setTimeout> | null;
inputBuffer: string;
};
const MAX_SESSIONS = 8;
let sessions = $state<SessionDisplay[]>([]);
const internals = new Map<number, SessionInternal>();
let activeSessionId = $state<number | null>(null);
let nextId = 0;
let cssLoaded = false;
let containerRef = $state<HTMLDivElement | undefined>(undefined);
let hasAutoCreated = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let TerminalClass: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let FitAddonClass: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let WebLinksAddonClass: any = null;
const activeSession = $derived(sessions.find(s => s.id === activeSessionId) ?? null);
const TERM_THEME = {
background: '#0a0c0b',
foreground: '#d0cdc6',
cursor: '#5e8c58',
cursorAccent: '#0a0c0b',
selectionBackground: 'rgba(94, 140, 88, 0.25)',
selectionForeground: '#eae7e2',
selectionInactiveBackground: 'rgba(94, 140, 88, 0.12)',
black: '#1a1e1c',
red: '#cf8172',
green: '#5e8c58',
yellow: '#d4a73c',
blue: '#5a9fd4',
magenta: '#b07ab8',
cyan: '#5aafb0',
white: '#d0cdc6',
brightBlack: '#454340',
brightRed: '#e09585',
brightGreen: '#89a785',
brightYellow: '#e0c070',
brightBlue: '#7ab8e0',
brightMagenta: '#c898cf',
brightCyan: '#7ac5c6',
brightWhite: '#eae7e2',
};
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
function toBase64(str: string): string {
return btoa(
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
);
}
// Binary-safe base64 decode (handles raw PTY bytes)
function fromBase64(b64: string): string {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty`;
}
function wsSend(ws: WebSocket | null, data: string) {
try {
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
} catch {
// Connection closing — ignore
}
}
function updateSession(id: number, updates: Partial<SessionDisplay>) {
const idx = sessions.findIndex(s => s.id === id);
if (idx === -1) return;
Object.assign(sessions[idx], updates);
}
async function loadModules() {
if (TerminalClass) return;
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links')
]);
TerminalClass = Terminal;
FitAddonClass = FitAddon;
WebLinksAddonClass = WebLinksAddon;
if (!cssLoaded) {
await import('@xterm/xterm/css/xterm.css');
cssLoaded = true;
}
}
// Create first session when the tab becomes visible for the first time
$effect(() => {
if (visible && isRunning && !hasAutoCreated && containerRef) {
hasAutoCreated = true;
createSession();
}
});
// Re-fit active terminal when tab becomes visible (after being hidden)
$effect(() => {
if (visible && activeSessionId !== null) {
const int = internals.get(activeSessionId);
if (int?.fitAddon && int.term) {
requestAnimationFrame(() => {
int.fitAddon.fit();
int.term.focus();
});
}
}
});
// Close all sessions when capsule stops running
$effect(() => {
if (!isRunning && sessions.length > 0) {
// Copy IDs to avoid mutating during iteration
const ids = sessions.map(s => s.id);
for (const id of ids) closeSession(id);
}
});
async function createSession() {
if (!isRunning || !containerRef) return;
if (sessions.length >= MAX_SESSIONS) return;
await loadModules();
const id = nextId++;
sessions = [...sessions, {
id,
state: 'connecting',
errorMessage: null,
ptyTag: null,
ptyPid: null,
}];
activeSessionId = id;
await tick();
const el = containerRef?.querySelector(`[data-session-id="${id}"]`) as HTMLDivElement | null;
if (!el) {
// DOM didn't render — clean up the orphaned display entry
sessions = sessions.filter(s => s.id !== id);
if (activeSessionId === id) activeSessionId = null;
return;
}
const fitAddon = new FitAddonClass();
const term = new TerminalClass({
cursorBlink: true,
cursorStyle: 'bar',
cursorInactiveStyle: 'outline',
fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
fontSize: 14,
lineHeight: 1.35,
letterSpacing: 0,
theme: TERM_THEME,
allowProposedApi: true,
scrollback: 5000,
convertEol: true,
});
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddonClass());
term.open(el);
const internal: SessionInternal = {
term,
fitAddon,
ws: null,
resizeObserver: null,
fitDebounce: null,
inputFlushTimer: null,
inputBuffer: '',
};
internals.set(id, internal);
requestAnimationFrame(() => fitAddon.fit());
internal.resizeObserver = new ResizeObserver(() => {
if (internal.fitDebounce) clearTimeout(internal.fitDebounce);
internal.fitDebounce = setTimeout(() => {
if (internal.fitAddon && internal.term && activeSessionId === id) {
internal.fitAddon.fit();
}
}, 50);
});
internal.resizeObserver.observe(el);
// Register input/resize handlers ONCE per terminal (not per connection).
function flushInput() {
const int = internals.get(id);
if (!int) return;
int.inputFlushTimer = null;
if (!int.inputBuffer) return;
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
int.inputBuffer = '';
}
term.onData((data: string) => {
const int = internals.get(id);
if (!int) return;
int.inputBuffer += data;
if (!int.inputFlushTimer) {
int.inputFlushTimer = setTimeout(flushInput, 50);
}
});
term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
const i = internals.get(id);
wsSend(i?.ws ?? null, JSON.stringify({ type: 'resize', cols, rows }));
});
connectSession(id);
}
function connectSession(id: number, reconnectTag?: string) {
const int = internals.get(id);
if (!int) return;
if (!auth.token) {
updateSession(id, { state: 'error', errorMessage: 'Not authenticated' });
return;
}
const display = sessions.find(s => s.id === id);
const tag = reconnectTag ?? display?.ptyTag;
const ws = new WebSocket(getWsUrl());
int.ws = ws;
updateSession(id, { state: 'connecting', errorMessage: null });
ws.onopen = () => {
// Send auth as the first message (JWT no longer in URL).
wsSend(ws, JSON.stringify({ type: 'auth', token: auth.token }));
const { cols, rows } = int.term;
const msg: Record<string, unknown> = {
type: tag ? 'connect' : 'start',
cols,
rows,
};
if (tag) {
msg.tag = tag;
} else {
msg.cmd = '/bin/bash';
msg.envs = { TERM: 'xterm-256color' };
}
wsSend(ws, JSON.stringify(msg));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'started':
updateSession(id, {
state: 'connected',
ptyTag: msg.tag,
ptyPid: msg.pid ?? null,
});
if (activeSessionId === id) int.term.focus();
break;
case 'output':
if (msg.data) int.term.write(fromBase64(msg.data));
break;
case 'exit':
closeSession(id);
break;
case 'error':
if (msg.fatal) {
updateSession(id, { state: 'error', errorMessage: msg.data || 'Connection error' });
int.term.write(`\r\n\x1b[38;2;207;129;114m${msg.data}\x1b[0m\r\n`);
}
break;
case 'ping':
wsSend(ws, JSON.stringify({ type: 'pong' }));
break;
}
} catch {
// Ignore malformed messages
}
};
ws.onclose = (event) => {
const s = sessions.find(s => s.id === id);
if (!s) return;
// Abnormal close with a live session — auto-reconnect
if (!event.wasClean && s.state === 'connected' && s.ptyTag) {
updateSession(id, { state: 'connecting', errorMessage: null });
int.term.write('\r\n\x1b[38;2;107;104;98m[reconnecting...]\x1b[0m\r\n');
setTimeout(() => connectSession(id, s.ptyTag ?? undefined), 1000);
return;
}
if (s.state === 'connected') {
updateSession(id, { state: 'disconnected' });
}
};
ws.onerror = () => {
updateSession(id, { state: 'error', errorMessage: 'Connection lost — check that the capsule is running' });
};
}
function switchTo(id: number) {
activeSessionId = id;
requestAnimationFrame(() => {
const int = internals.get(id);
if (int?.fitAddon && int.term) {
int.fitAddon.fit();
int.term.focus();
}
});
}
function closeSession(id: number) {
const idx = sessions.findIndex(s => s.id === id);
if (idx === -1) return;
const int = internals.get(id);
if (int) {
if (int.fitDebounce) clearTimeout(int.fitDebounce);
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
int.resizeObserver?.disconnect();
wsSend(int.ws, JSON.stringify({ type: 'kill' }));
int.ws?.close();
int.term?.dispose();
internals.delete(id);
}
sessions = sessions.filter(s => s.id !== id);
if (activeSessionId === id) {
if (sessions.length === 0) {
activeSessionId = null;
} else {
const newIdx = Math.min(idx, sessions.length - 1);
switchTo(sessions[newIdx].id);
}
}
}
function reconnectSession(id: number) {
const int = internals.get(id);
const display = sessions.find(s => s.id === id);
if (!int || !display) return;
int.ws?.close();
connectSession(id, display.ptyTag ?? undefined);
}
function statusDot(state: ConnectionState): string {
switch (state) {
case 'connected': return 'bg-[var(--color-accent)]';
case 'connecting': return 'bg-[var(--color-text-tertiary)] animate-pulse';
case 'error': return 'bg-[var(--color-red)]';
default: return 'bg-[var(--color-text-muted)]';
}
}
onDestroy(() => {
for (const [, int] of internals) {
if (int.fitDebounce) clearTimeout(int.fitDebounce);
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
int.resizeObserver?.disconnect();
int.ws?.close();
int.term?.dispose();
}
internals.clear();
});
</script>
<style>
.terminal-container :global(.xterm) {
padding: 12px 4px 12px 16px;
height: 100%;
}
.terminal-container :global(.xterm-viewport),
.terminal-container :global(.xterm-screen) {
background-color: #0a0c0b !important;
}
.terminal-container :global(.xterm-viewport) {
scrollbar-width: thin;
scrollbar-color: rgba(94, 140, 88, 0.18) transparent;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar) {
width: 6px;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-track) {
background: transparent;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(94, 140, 88, 0.18);
border-radius: 3px;
}
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(94, 140, 88, 0.32);
}
.tab-scroll {
scrollbar-width: none;
}
.tab-scroll::-webkit-scrollbar {
display: none;
}
.term-tab {
position: relative;
}
.term-tab::after {
content: '';
position: absolute;
right: 0;
top: 25%;
bottom: 25%;
width: 1px;
background: var(--color-border);
}
.term-tab:last-child::after {
display: none;
}
.term-tab-active::after {
display: none;
}
.term-tab:has(+ .term-tab-active)::after {
display: none;
}
</style>
<div class="flex flex-1 flex-col min-h-0">
{#if !isRunning}
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-5 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body font-medium text-[var(--color-text-secondary)]">Terminal unavailable</span>
<span class="text-ui text-[var(--color-text-muted)]">Start the capsule to connect</span>
</div>
</div>
</div>
{:else}
<!-- Unified session bar (hidden when no sessions) -->
<div class="flex items-stretch bg-[var(--color-bg-1)]" style:display={sessions.length === 0 ? 'none' : 'flex'}>
<div class="tab-scroll flex items-stretch overflow-x-auto">
{#each sessions as session (session.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => switchTo(session.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') switchTo(session.id); }}
role="tab"
tabindex="0"
aria-selected={session.id === activeSessionId}
class="term-tab group flex shrink-0 cursor-pointer items-center gap-2.5 px-5 py-2.5 text-meta transition-colors
{session.id === activeSessionId
? 'term-tab-active bg-[var(--color-bg-0)] text-[var(--color-text-primary)]'
: 'bg-[var(--color-bg-1)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-secondary)] border-b border-b-[var(--color-border)]'}"
>
{#if session.state === 'connected'}
<span class="relative flex h-[7px] w-[7px] shrink-0">
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
<span class="relative inline-flex h-[7px] w-[7px] rounded-full bg-[var(--color-accent)]"></span>
</span>
{:else if session.state === 'connecting'}
<svg class="animate-spin shrink-0 text-[var(--color-text-tertiary)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{:else if session.state === 'error'}
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-red)]"></span>
{:else}
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-text-muted)]"></span>
{/if}
<span class="font-mono">
bash{#if session.ptyPid}<span class="text-[var(--color-text-muted)]">:{session.ptyPid}</span>{/if}
</span>
<button
onclick={(e) => { e.stopPropagation(); closeSession(session.id); }}
class="ml-0.5 flex h-5 w-5 items-center justify-center rounded-[3px] text-[var(--color-text-muted)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-secondary)]"
title="Close session"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/each}
</div>
<button
onclick={createSession}
disabled={sessions.length >= MAX_SESSIONS}
class="flex shrink-0 items-center justify-center aspect-square self-stretch border-b border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-primary)] disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-tertiary)]"
title={sessions.length >= MAX_SESSIONS ? `Maximum ${MAX_SESSIONS} sessions` : 'New terminal session'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<div class="flex-1 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]"></div>
{#if activeSession}
<div class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] pr-4">
{#if activeSession.state === 'error' && activeSession.errorMessage}
<span class="text-meta text-[var(--color-red)]/70">{activeSession.errorMessage}</span>
{/if}
{#if (activeSession.state === 'disconnected' || activeSession.state === 'error') && activeSession.ptyTag}
<button
onclick={() => activeSession && reconnectSession(activeSession.id)}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-3 py-1 text-meta font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
Reconnect
</button>
{/if}
{#if activeSession.ptyTag}
<span class="font-mono text-label text-[var(--color-text-muted)]">{activeSession.ptyTag}</span>
{/if}
</div>
{/if}
</div>
<!-- Terminal surfaces -->
<div class="relative flex-1 min-h-0 bg-[var(--color-bg-0)]" bind:this={containerRef}>
{#each sessions as session (session.id)}
<div
data-session-id={session.id}
class="terminal-container absolute inset-0 bg-[var(--color-bg-0)]"
style:display={session.id === activeSessionId ? 'block' : 'none'}
></div>
{/each}
{#if sessions.length === 0}
<div class="flex h-full items-center justify-center">
<div class="flex flex-col items-center gap-5 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body font-medium text-[var(--color-text-secondary)]">No active sessions</span>
<span class="text-ui text-[var(--color-text-muted)]">All terminal sessions have been closed</span>
</div>
<button
onclick={createSession}
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-5 py-2.5 text-ui font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
New session
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@ -13,6 +13,7 @@
<span class="flex-1 leading-relaxed">{t.message}</span>
<button
onclick={() => toast.dismiss(t.id)}
aria-label="Dismiss"
class="mt-0.5 shrink-0 opacity-50 transition-opacity hover:opacity-100"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">

View File

@ -0,0 +1,128 @@
/**
* Lazy syntax highlighting via shiki.
*
* The highlighter WASM engine + theme are loaded on first use.
* Language grammars load on-demand per extension.
* All imports are dynamic so nothing touches the main bundle.
*/
import type { HighlighterGeneric, ThemedToken } from 'shiki';
export type { ThemedToken };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let highlighter: HighlighterGeneric<any, any> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
const THEME = 'vesper';
// Extensions → shiki language IDs.
// Only map what we expect users to encounter in capsules.
const EXT_TO_LANG: Record<string, string> = {
// Go
go: 'go', mod: 'go', sum: 'go',
// Python
py: 'python', pyi: 'python', pyx: 'python',
// JavaScript / TypeScript
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
// Rust
rs: 'rust',
// Shell
sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript',
// Config
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
env: 'shellscript',
// Markup / docs
md: 'markdown', mdx: 'mdx', html: 'html', htm: 'html', xml: 'xml',
// CSS
css: 'css', scss: 'scss', less: 'less',
// SQL
sql: 'sql',
// Svelte / Vue
svelte: 'svelte', vue: 'vue',
// Docker / Make
dockerfile: 'dockerfile',
makefile: 'makefile',
// Proto
proto: 'protobuf',
// C / C++
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
// Java / Kotlin
java: 'java', kt: 'kotlin', kts: 'kotlin',
// Ruby
rb: 'ruby',
// PHP
php: 'php',
// Lua
lua: 'lua',
// Misc
txt: 'plaintext',
};
// Filenames without extensions
const NAME_TO_LANG: Record<string, string> = {
Dockerfile: 'dockerfile',
Makefile: 'makefile',
Containerfile: 'dockerfile',
Vagrantfile: 'ruby',
};
/** Resolve a filename to a shiki language ID, or null if unknown. */
export function langFromFilename(name: string): string | null {
// Check full filename first (Dockerfile, Makefile, etc.)
const basename = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
if (NAME_TO_LANG[basename]) return NAME_TO_LANG[basename];
const dot = basename.lastIndexOf('.');
if (dot <= 0) return null;
const ext = basename.slice(dot + 1).toLowerCase();
return EXT_TO_LANG[ext] ?? null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
if (highlighter) return highlighter;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
const { createHighlighter } = await import('shiki');
const h = await createHighlighter({
themes: [THEME],
langs: [], // load languages on demand
});
highlighter = h;
return h;
})();
return loadingPromise;
}
/**
* Tokenize code for a given language.
* Returns an array of lines, each containing themed tokens with `color` and `content`.
* Returns null if the language is unknown or highlighting fails.
*/
export async function tokenize(
code: string,
filename: string,
): Promise<ThemedToken[][] | null> {
const lang = langFromFilename(filename);
if (!lang || lang === 'plaintext') return null;
try {
const h = await getHighlighter();
// Load grammar on demand if not yet loaded
const loaded = h.getLoadedLanguages();
if (!loaded.includes(lang)) {
await h.loadLanguage(lang);
}
return h.codeToTokensBase(code, { lang, theme: THEME });
} catch {
// Grammar not available or other error — fall back to plain text
return null;
}
}