feat(v2): add SSH management, ctx integration, themes, detached mode, auto-updater
SSH session management: - SshSession struct + ssh_sessions SQLite table in session.rs - CRUD Tauri commands (ssh_session_list/save/delete) in lib.rs - SshDialog.svelte (create/edit modal), SshSessionList.svelte (sidebar) - SSH pane routes to TerminalPane with shell=/usr/bin/ssh + args ctx context database integration: - ctx.rs: read-only CtxDb (SQLITE_OPEN_READ_ONLY for ~/.claude-context/context.db) - 5 Tauri commands (ctx_list_projects/get_context/get_shared/get_summaries/search) - ContextPane.svelte with project selector, tabs, search - ctx-bridge.ts adapter Catppuccin theme flavors (Latte/Frappe/Macchiato/Mocha): - themes.ts: all 4 palette definitions + buildXtermTheme/applyCssVariables - theme.svelte.ts: reactive store with SQLite persistence - SettingsDialog flavor dropdown, TerminalPane theme-aware Detached pane mode (pop-out windows): - detach.ts: isDetachedMode/getDetachedConfig from URL params - App.svelte: conditional rendering of single pane without chrome Other additions: - Shiki syntax highlighting (highlight.ts, lazy singleton, 13 languages) - Tauri auto-updater plugin (tauri-plugin-updater + updater.ts) - AgentPane markdown rendering with Shiki code highlighting - New deps: shiki, @tauri-apps/plugin-updater, tauri-plugin-updater
This commit is contained in:
parent
4f2614186d
commit
4db7ccff60
28 changed files with 2992 additions and 51 deletions
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { marked, Renderer } from 'marked';
|
||||
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
|
||||
import {
|
||||
getAgentSession,
|
||||
|
|
@ -9,6 +10,7 @@
|
|||
} from '../../stores/agents.svelte';
|
||||
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
||||
import AgentTree from './AgentTree.svelte';
|
||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||
import type {
|
||||
AgentMessage,
|
||||
TextContent,
|
||||
|
|
@ -36,7 +38,25 @@
|
|||
let showTree = $state(false);
|
||||
let hasToolCalls = $derived(session?.messages.some(m => m.type === 'tool_call') ?? false);
|
||||
|
||||
const mdRenderer = new Renderer();
|
||||
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||
if (lang) {
|
||||
const highlighted = highlightCode(text, lang);
|
||||
if (highlighted !== escapeHtml(text)) return highlighted;
|
||||
}
|
||||
return `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
};
|
||||
|
||||
function renderMarkdown(source: string): string {
|
||||
try {
|
||||
return marked.parse(source, { renderer: mdRenderer, async: false }) as string;
|
||||
} catch {
|
||||
return escapeHtml(source);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await getHighlighter();
|
||||
if (initialPrompt) {
|
||||
await startQuery(initialPrompt);
|
||||
}
|
||||
|
|
@ -164,7 +184,7 @@
|
|||
<span class="model">{(msg.content as import('../../adapters/sdk-messages').InitContent).model}</span>
|
||||
</div>
|
||||
{:else if msg.type === 'text'}
|
||||
<div class="msg-text">{(msg.content as TextContent).text}</div>
|
||||
<div class="msg-text markdown-body">{@html renderMarkdown((msg.content as TextContent).text)}</div>
|
||||
{:else if msg.type === 'thinking'}
|
||||
<details class="msg-thinking">
|
||||
<summary>Thinking...</summary>
|
||||
|
|
@ -217,6 +237,9 @@
|
|||
<span class="cost">${session.costUsd.toFixed(4)}</span>
|
||||
<span class="tokens">{session.inputTokens + session.outputTokens} tokens</span>
|
||||
<span class="duration">{(session.durationMs / 1000).toFixed(1)}s</span>
|
||||
{#if !autoScroll}
|
||||
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollToBottom(); }}>Scroll to bottom</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if session.status === 'error'}
|
||||
<div class="error-bar">
|
||||
|
|
@ -334,11 +357,118 @@
|
|||
}
|
||||
|
||||
.msg-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(h1) {
|
||||
font-size: 1.4em;
|
||||
font-weight: 700;
|
||||
margin: 0.6em 0 0.3em;
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(h2) {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
margin: 0.5em 0 0.3em;
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(h3) {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
margin: 0.4em 0 0.2em;
|
||||
color: var(--ctp-sapphire);
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(p) {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(code) {
|
||||
background: var(--bg-surface);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(pre) {
|
||||
background: var(--bg-surface);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(.shiki) {
|
||||
background: var(--bg-surface) !important;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(.shiki code) {
|
||||
background: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(blockquote) {
|
||||
border-left: 3px solid var(--ctp-mauve);
|
||||
margin: 0.4em 0;
|
||||
padding: 2px 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(ul), .msg-text.markdown-body :global(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(li) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(a) {
|
||||
color: var(--ctp-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.4em 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(th), .msg-text.markdown-body :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.msg-text.markdown-body :global(th) {
|
||||
background: var(--bg-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.msg-thinking {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 12px;
|
||||
|
|
|
|||
340
v2/src/lib/components/Context/ContextPane.svelte
Normal file
340
v2/src/lib/components/Context/ContextPane.svelte
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
ctxListProjects,
|
||||
ctxGetContext,
|
||||
ctxGetShared,
|
||||
ctxGetSummaries,
|
||||
ctxSearch,
|
||||
type CtxProject,
|
||||
type CtxEntry,
|
||||
type CtxSummary,
|
||||
} from '../../adapters/ctx-bridge';
|
||||
|
||||
interface Props {
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
let { onExit }: Props = $props();
|
||||
|
||||
let projects = $state<CtxProject[]>([]);
|
||||
let selectedProject = $state<string | null>(null);
|
||||
let entries = $state<CtxEntry[]>([]);
|
||||
let sharedEntries = $state<CtxEntry[]>([]);
|
||||
let summaries = $state<CtxSummary[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<CtxEntry[]>([]);
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
projects = await ctxListProjects();
|
||||
sharedEntries = await ctxGetShared();
|
||||
} catch (e) {
|
||||
error = `ctx database not available: ${e}`;
|
||||
}
|
||||
});
|
||||
|
||||
async function selectProject(name: string) {
|
||||
selectedProject = name;
|
||||
loading = true;
|
||||
try {
|
||||
[entries, summaries] = await Promise.all([
|
||||
ctxGetContext(name),
|
||||
ctxGetSummaries(name, 5),
|
||||
]);
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = `Failed to load context: ${e}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
searchResults = await ctxSearch(searchQuery);
|
||||
} catch (e) {
|
||||
error = `Search failed: ${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="context-pane">
|
||||
<div class="ctx-header">
|
||||
<h3>Context Manager</h3>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search contexts..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="ctx-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="ctx-body">
|
||||
{#if searchResults.length > 0}
|
||||
<div class="section">
|
||||
<h4>Search Results</h4>
|
||||
{#each searchResults as result}
|
||||
<div class="entry">
|
||||
<div class="entry-header">
|
||||
<span class="entry-project">{result.project}</span>
|
||||
<span class="entry-key">{result.key}</span>
|
||||
</div>
|
||||
<pre class="entry-value">{result.value}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="clear-btn" onclick={() => { searchResults = []; searchQuery = ''; }}>Clear search</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="project-list">
|
||||
<h4>Projects</h4>
|
||||
{#if projects.length === 0}
|
||||
<p class="empty">No projects registered. Use <code>ctx init</code> to add one.</p>
|
||||
{/if}
|
||||
{#each projects as project}
|
||||
<button
|
||||
class="project-btn"
|
||||
class:active={selectedProject === project.name}
|
||||
onclick={() => selectProject(project.name)}
|
||||
>
|
||||
<span class="project-name">{project.name}</span>
|
||||
<span class="project-desc">{project.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if sharedEntries.length > 0}
|
||||
<div class="section">
|
||||
<h4>Shared Context</h4>
|
||||
{#each sharedEntries as entry}
|
||||
<div class="entry">
|
||||
<div class="entry-header">
|
||||
<span class="entry-key">{entry.key}</span>
|
||||
</div>
|
||||
<pre class="entry-value">{entry.value}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedProject && !loading}
|
||||
<div class="section">
|
||||
<h4>{selectedProject} Context</h4>
|
||||
{#if entries.length === 0}
|
||||
<p class="empty">No context entries for this project.</p>
|
||||
{/if}
|
||||
{#each entries as entry}
|
||||
<div class="entry">
|
||||
<div class="entry-header">
|
||||
<span class="entry-key">{entry.key}</span>
|
||||
<span class="entry-date">{entry.updated_at}</span>
|
||||
</div>
|
||||
<pre class="entry-value">{entry.value}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if summaries.length > 0}
|
||||
<div class="section">
|
||||
<h4>Recent Sessions</h4>
|
||||
{#each summaries as summary}
|
||||
<div class="entry">
|
||||
<div class="entry-header">
|
||||
<span class="entry-date">{summary.created_at}</span>
|
||||
</div>
|
||||
<pre class="entry-value">{summary.summary}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ctx-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctx-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ctx-error {
|
||||
color: var(--ctp-red);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ctx-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-mauve);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.project-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.project-btn:hover { border-color: var(--accent); }
|
||||
.project-btn.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.entry-project {
|
||||
font-size: 10px;
|
||||
color: var(--ctp-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entry-key {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.entry-date {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.entry-value {
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-secondary);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.clear-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,10 +5,11 @@
|
|||
title: string;
|
||||
status?: 'idle' | 'running' | 'error' | 'done';
|
||||
onClose?: () => void;
|
||||
onDetach?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, status = 'idle', onClose, children }: Props = $props();
|
||||
let { title, status = 'idle', onClose, onDetach, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pane-container">
|
||||
|
|
@ -18,6 +19,9 @@
|
|||
{#if status !== 'idle'}
|
||||
<span class="status {status}">{status}</span>
|
||||
{/if}
|
||||
{#if onDetach}
|
||||
<button class="detach-btn" onclick={onDetach} title="Pop out to new window">↗</button>
|
||||
{/if}
|
||||
{#if onClose}
|
||||
<button class="close-btn" onclick={onClose} title="Close pane">×</button>
|
||||
{/if}
|
||||
|
|
@ -75,6 +79,18 @@
|
|||
.status.error { color: var(--ctp-red); }
|
||||
.status.done { color: var(--ctp-green); }
|
||||
|
||||
.detach-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detach-btn:hover { color: var(--ctp-blue); }
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import TerminalPane from '../Terminal/TerminalPane.svelte';
|
||||
import AgentPane from '../Agent/AgentPane.svelte';
|
||||
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
|
||||
import ContextPane from '../Context/ContextPane.svelte';
|
||||
import {
|
||||
getPanes,
|
||||
getGridTemplate,
|
||||
|
|
@ -10,9 +11,17 @@
|
|||
focusPane,
|
||||
removePane,
|
||||
} from '../../stores/layout.svelte';
|
||||
import { detachPane } from '../../utils/detach';
|
||||
import { isDetachedMode } from '../../utils/detach';
|
||||
|
||||
let gridTemplate = $derived(getGridTemplate());
|
||||
let panes = $derived(getPanes());
|
||||
let detached = isDetachedMode();
|
||||
|
||||
function handleDetach(pane: typeof panes[0]) {
|
||||
detachPane(pane);
|
||||
removePane(pane.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -41,6 +50,7 @@
|
|||
title={pane.title}
|
||||
status={pane.focused ? 'running' : 'idle'}
|
||||
onClose={() => removePane(pane.id)}
|
||||
onDetach={detached ? undefined : () => handleDetach(pane)}
|
||||
>
|
||||
{#if pane.type === 'terminal'}
|
||||
<TerminalPane
|
||||
|
|
@ -55,6 +65,15 @@
|
|||
cwd={pane.cwd}
|
||||
onExit={() => removePane(pane.id)}
|
||||
/>
|
||||
{:else if pane.type === 'ssh'}
|
||||
<TerminalPane
|
||||
shell={pane.shell}
|
||||
cwd={pane.cwd}
|
||||
args={pane.args}
|
||||
onExit={() => removePane(pane.id)}
|
||||
/>
|
||||
{:else if pane.type === 'context'}
|
||||
<ContextPane onExit={() => removePane(pane.id)} />
|
||||
{:else if pane.type === 'markdown'}
|
||||
<MarkdownPane
|
||||
paneId={pane.id}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { marked } from 'marked';
|
||||
import { marked, Renderer } from 'marked';
|
||||
import { watchFile, unwatchFile, onFileChanged, type FileChangedPayload } from '../../adapters/file-bridge';
|
||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||
|
||||
interface Props {
|
||||
filePath: string;
|
||||
|
|
@ -15,9 +16,18 @@
|
|||
let error = $state('');
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const renderer = new Renderer();
|
||||
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||
if (lang) {
|
||||
const highlighted = highlightCode(text, lang);
|
||||
if (highlighted !== escapeHtml(text)) return highlighted;
|
||||
}
|
||||
return `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
};
|
||||
|
||||
function renderMarkdown(source: string): void {
|
||||
try {
|
||||
renderedHtml = marked.parse(source, { async: false }) as string;
|
||||
renderedHtml = marked.parse(source, { renderer, async: false }) as string;
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = `Render error: ${e}`;
|
||||
|
|
@ -26,6 +36,7 @@
|
|||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await getHighlighter();
|
||||
const content = await watchFile(paneId, filePath);
|
||||
renderMarkdown(content);
|
||||
|
||||
|
|
@ -125,6 +136,21 @@
|
|||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-body :global(.shiki) {
|
||||
background: var(--bg-surface) !important;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.markdown-body :global(.shiki code) {
|
||||
background: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body :global(blockquote) {
|
||||
border-left: 3px solid var(--ctp-mauve);
|
||||
margin: 0.5em 0;
|
||||
|
|
|
|||
281
v2/src/lib/components/SSH/SshDialog.svelte
Normal file
281
v2/src/lib/components/SSH/SshDialog.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import { notify } from '../../stores/notifications.svelte';
|
||||
import { saveSshSession, type SshSession } from '../../adapters/ssh-bridge';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
editSession?: SshSession;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
let { open, editSession, onClose, onSaved }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let host = $state('');
|
||||
let port = $state(22);
|
||||
let username = $state('');
|
||||
let keyFile = $state('');
|
||||
let folder = $state('');
|
||||
|
||||
let validationError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (open && editSession) {
|
||||
name = editSession.name;
|
||||
host = editSession.host;
|
||||
port = editSession.port;
|
||||
username = editSession.username;
|
||||
keyFile = editSession.key_file;
|
||||
folder = editSession.folder;
|
||||
} else if (open) {
|
||||
name = '';
|
||||
host = '';
|
||||
port = 22;
|
||||
username = '';
|
||||
keyFile = '';
|
||||
folder = '';
|
||||
}
|
||||
validationError = '';
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
if (!name.trim()) {
|
||||
validationError = 'Name is required';
|
||||
return false;
|
||||
}
|
||||
if (!host.trim()) {
|
||||
validationError = 'Host is required';
|
||||
return false;
|
||||
}
|
||||
if (!username.trim()) {
|
||||
validationError = 'Username is required';
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
validationError = 'Port must be between 1 and 65535';
|
||||
return false;
|
||||
}
|
||||
validationError = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validate()) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const session: SshSession = {
|
||||
id: editSession?.id ?? crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
host: host.trim(),
|
||||
port,
|
||||
username: username.trim(),
|
||||
key_file: keyFile.trim(),
|
||||
folder: folder.trim(),
|
||||
color: editSession?.color ?? '#89b4fa',
|
||||
created_at: editSession?.created_at ?? now,
|
||||
last_used_at: now,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveSshSession(session);
|
||||
notify('success', `SSH session "${session.name}" saved`);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
notify('error', `Failed to save SSH session: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="backdrop" onclick={onClose}></div>
|
||||
<div class="dialog" role="dialog" aria-label="SSH Session">
|
||||
<div class="dialog-header">
|
||||
<h2>{editSession ? 'Edit' : 'New'} SSH Session</h2>
|
||||
<button class="close-btn" onclick={onClose}>×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
{#if validationError}
|
||||
<div class="validation-error">{validationError}</div>
|
||||
{/if}
|
||||
<label class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<input type="text" bind:value={name} placeholder="My Server" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Host</span>
|
||||
<input type="text" bind:value={host} placeholder="192.168.1.100 or server.example.com" />
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field" style="flex: 1;">
|
||||
<span class="field-label">Username</span>
|
||||
<input type="text" bind:value={username} placeholder="root" />
|
||||
</label>
|
||||
<label class="field" style="width: 100px;">
|
||||
<span class="field-label">Port</span>
|
||||
<input type="number" bind:value={port} min="1" max="65535" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">SSH Key (optional)</span>
|
||||
<input type="text" bind:value={keyFile} placeholder="~/.ssh/id_ed25519" />
|
||||
<span class="field-hint">Leave empty to use default key or password auth</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Folder (optional)</span>
|
||||
<input type="text" bind:value={folder} placeholder="Group name for organizing" />
|
||||
<span class="field-hint">Sessions with the same folder are grouped together</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-cancel" onclick={onClose}>Cancel</button>
|
||||
<button class="btn-save" onclick={handleSave}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
width: 420px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
border: 1px solid var(--ctp-red);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--ctp-red);
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-cancel, .btn-save {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-cancel:hover { color: var(--text-primary); }
|
||||
|
||||
.btn-save {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.btn-save:hover { opacity: 0.9; }
|
||||
</style>
|
||||
263
v2/src/lib/components/SSH/SshSessionList.svelte
Normal file
263
v2/src/lib/components/SSH/SshSessionList.svelte
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listSshSessions, deleteSshSession, type SshSession } from '../../adapters/ssh-bridge';
|
||||
import { addPane } from '../../stores/layout.svelte';
|
||||
import { notify } from '../../stores/notifications.svelte';
|
||||
import SshDialog from './SshDialog.svelte';
|
||||
|
||||
let sessions = $state<SshSession[]>([]);
|
||||
let dialogOpen = $state(false);
|
||||
let editingSession = $state<SshSession | undefined>(undefined);
|
||||
|
||||
onMount(() => {
|
||||
loadSessions();
|
||||
});
|
||||
|
||||
async function loadSessions() {
|
||||
try {
|
||||
sessions = await listSshSessions();
|
||||
} catch (e) {
|
||||
console.warn('Failed to load SSH sessions:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function openNewDialog() {
|
||||
editingSession = undefined;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEditDialog(session: SshSession) {
|
||||
editingSession = session;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function connectSsh(session: SshSession) {
|
||||
const id = crypto.randomUUID();
|
||||
const args: string[] = [];
|
||||
|
||||
// Build ssh command arguments
|
||||
args.push('-p', String(session.port));
|
||||
if (session.key_file) {
|
||||
args.push('-i', session.key_file);
|
||||
}
|
||||
args.push(`${session.username}@${session.host}`);
|
||||
|
||||
addPane({
|
||||
id,
|
||||
type: 'ssh',
|
||||
title: `SSH: ${session.name}`,
|
||||
shell: '/usr/bin/ssh',
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(session: SshSession, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await deleteSshSession(session.id);
|
||||
sessions = sessions.filter(s => s.id !== session.id);
|
||||
notify('success', `Deleted SSH session "${session.name}"`);
|
||||
} catch (e) {
|
||||
notify('error', `Failed to delete SSH session: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Group sessions by folder
|
||||
let grouped = $derived(() => {
|
||||
const groups = new Map<string, SshSession[]>();
|
||||
for (const s of sessions) {
|
||||
const folder = s.folder || '';
|
||||
if (!groups.has(folder)) groups.set(folder, []);
|
||||
groups.get(folder)!.push(s);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ssh-sessions">
|
||||
<div class="ssh-header">
|
||||
<h3>SSH</h3>
|
||||
<button class="new-btn" onclick={openNewDialog} title="Add SSH session">+</button>
|
||||
</div>
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No SSH sessions.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{@const groups = grouped()}
|
||||
{#each [...groups.entries()] as [folder, folderSessions] (folder)}
|
||||
{#if folder}
|
||||
<div class="folder-label">{folder}</div>
|
||||
{/if}
|
||||
<ul class="ssh-list">
|
||||
{#each folderSessions as session (session.id)}
|
||||
<li class="ssh-item">
|
||||
<button class="ssh-btn" onclick={() => connectSsh(session)} title="Connect to {session.host}">
|
||||
<span class="ssh-color" style:background={session.color}></span>
|
||||
<span class="ssh-info">
|
||||
<span class="ssh-name">{session.name}</span>
|
||||
<span class="ssh-host">{session.username}@{session.host}:{session.port}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="edit-btn" onclick={() => openEditDialog(session)} title="Edit">E</button>
|
||||
<button class="remove-btn" onclick={(e) => handleDelete(session, e)} title="Delete">×</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SshDialog
|
||||
open={dialogOpen}
|
||||
editSession={editingSession}
|
||||
onClose={() => { dialogOpen = false; }}
|
||||
onSaved={loadSessions}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.ssh-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ssh-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ssh-header h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.new-btn:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.folder-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 0 2px;
|
||||
}
|
||||
|
||||
.ssh-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.ssh-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.ssh-item:hover {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.ssh-btn {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ssh-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.ssh-color {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ssh-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ssh-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ssh-host {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 2px 3px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ssh-item:hover .edit-btn { opacity: 1; }
|
||||
.ssh-item:hover .remove-btn { opacity: 1; }
|
||||
.edit-btn:hover { color: var(--ctp-yellow); }
|
||||
.remove-btn:hover { color: var(--ctp-red); }
|
||||
</style>
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { getSetting, setSetting } from '../../adapters/settings-bridge';
|
||||
import { notify } from '../../stores/notifications.svelte';
|
||||
import { getCurrentFlavor, setFlavor } from '../../stores/theme.svelte';
|
||||
import { ALL_FLAVORS, FLAVOR_LABELS, type CatppuccinFlavor } from '../../styles/themes';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -13,12 +15,14 @@
|
|||
let defaultShell = $state('');
|
||||
let defaultCwd = $state('');
|
||||
let maxPanes = $state('4');
|
||||
let themeFlavor = $state<CatppuccinFlavor>('mocha');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
defaultShell = (await getSetting('default_shell')) ?? '';
|
||||
defaultCwd = (await getSetting('default_cwd')) ?? '';
|
||||
maxPanes = (await getSetting('max_panes')) ?? '4';
|
||||
themeFlavor = getCurrentFlavor();
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
|
|
@ -29,6 +33,7 @@
|
|||
if (defaultShell) await setSetting('default_shell', defaultShell);
|
||||
if (defaultCwd) await setSetting('default_cwd', defaultCwd);
|
||||
await setSetting('max_panes', maxPanes);
|
||||
await setFlavor(themeFlavor);
|
||||
notify('success', 'Settings saved');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
|
|
@ -67,6 +72,15 @@
|
|||
<input type="number" bind:value={maxPanes} min="1" max="8" />
|
||||
<span class="field-hint">Maximum simultaneous panes (1-8)</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Theme</span>
|
||||
<select bind:value={themeFlavor}>
|
||||
{#each ALL_FLAVORS as flavor}
|
||||
<option value={flavor}>{FLAVOR_LABELS[flavor]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="field-hint">Catppuccin color scheme. New terminals use the updated theme.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-cancel" onclick={onClose}>Cancel</button>
|
||||
|
|
@ -146,7 +160,7 @@
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
.field input, .field select {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
|
|
@ -156,7 +170,7 @@
|
|||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
.field input:focus, .field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
setPreset,
|
||||
type LayoutPreset,
|
||||
} from '../../stores/layout.svelte';
|
||||
import SshSessionList from '../SSH/SshSessionList.svelte';
|
||||
|
||||
let panes = $derived(getPanes());
|
||||
let preset = $derived(getActivePreset());
|
||||
|
|
@ -34,6 +35,20 @@
|
|||
});
|
||||
}
|
||||
|
||||
function openContext() {
|
||||
const existing = panes.find(p => p.type === 'context');
|
||||
if (existing) {
|
||||
focusPane(existing.id);
|
||||
return;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
addPane({
|
||||
id,
|
||||
type: 'context',
|
||||
title: 'Context',
|
||||
});
|
||||
}
|
||||
|
||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
function openMarkdown() {
|
||||
|
|
@ -62,6 +77,7 @@
|
|||
<div class="header">
|
||||
<h2>Sessions</h2>
|
||||
<div class="header-buttons">
|
||||
<button class="new-btn" onclick={openContext} title="Context manager">C</button>
|
||||
<button class="new-btn" onclick={openMarkdown} title="Open markdown file">M</button>
|
||||
<button class="new-btn" onclick={newAgent} title="New agent (Ctrl+Shift+N)">A</button>
|
||||
<button class="new-btn" onclick={newTerminal} title="New terminal (Ctrl+N)">+</button>
|
||||
|
|
@ -96,7 +112,7 @@
|
|||
{#each panes as pane (pane.id)}
|
||||
<li class="pane-item" class:focused={pane.focused}>
|
||||
<button class="pane-btn" onclick={() => focusPane(pane.id)}>
|
||||
<span class="pane-icon">{pane.type === 'terminal' ? '>' : pane.type === 'agent' ? '*' : pane.type === 'markdown' ? 'M' : '#'}</span>
|
||||
<span class="pane-icon">{pane.type === 'terminal' ? '>' : pane.type === 'agent' ? '*' : pane.type === 'markdown' ? 'M' : pane.type === 'ssh' ? '@' : pane.type === 'context' ? 'C' : '#'}</span>
|
||||
<span class="pane-name">{pane.title}</span>
|
||||
</button>
|
||||
<button class="remove-btn" onclick={() => removePane(pane.id)}>×</button>
|
||||
|
|
@ -104,9 +120,18 @@
|
|||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
<SshSessionList />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
|
||||
import { getXtermTheme } from '../../stores/theme.svelte';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
|
|
@ -24,35 +25,9 @@
|
|||
let unlistenExit: UnlistenFn | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Catppuccin Mocha xterm theme
|
||||
const catppuccinTheme = {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
selectionBackground: '#45475a',
|
||||
selectionForeground: '#cdd6f4',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#f5c2e7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#f5c2e7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
term = new Terminal({
|
||||
theme: catppuccinTheme,
|
||||
theme: getXtermTheme(),
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue