feat(workspace): overhaul ProjectBox tab system with 6 tabs and lazy mount
This commit is contained in:
parent
f5f3e0d63e
commit
6744e1beaf
6 changed files with 1317 additions and 13 deletions
22
v2/src/lib/adapters/files-bridge.ts
Normal file
22
v2/src/lib/adapters/files-bridge.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export type FileContent =
|
||||
| { type: 'Text'; content: string; lang: string }
|
||||
| { type: 'Binary'; message: string }
|
||||
| { type: 'TooLarge'; size: number };
|
||||
|
||||
export function listDirectoryChildren(path: string): Promise<DirEntry[]> {
|
||||
return invoke<DirEntry[]>('list_directory_children', { path });
|
||||
}
|
||||
|
||||
export function readFileContent(path: string): Promise<FileContent> {
|
||||
return invoke<FileContent>('read_file_content', { path });
|
||||
}
|
||||
52
v2/src/lib/adapters/memory-adapter.ts
Normal file
52
v2/src/lib/adapters/memory-adapter.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Pluggable memory adapter interface.
|
||||
* Memora is the default implementation, but others can be swapped in.
|
||||
*/
|
||||
|
||||
export interface MemoryNode {
|
||||
id: string | number;
|
||||
content: string;
|
||||
tags: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface MemorySearchResult {
|
||||
nodes: MemoryNode[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MemoryAdapter {
|
||||
readonly name: string;
|
||||
readonly available: boolean;
|
||||
|
||||
/** List memories, optionally filtered by tags */
|
||||
list(options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemorySearchResult>;
|
||||
|
||||
/** Semantic search across memories */
|
||||
search(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemorySearchResult>;
|
||||
|
||||
/** Get a single memory by ID */
|
||||
get(id: string | number): Promise<MemoryNode | null>;
|
||||
}
|
||||
|
||||
/** Registry of available memory adapters */
|
||||
const adapters = new Map<string, MemoryAdapter>();
|
||||
|
||||
export function registerMemoryAdapter(adapter: MemoryAdapter): void {
|
||||
adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
export function getMemoryAdapter(name: string): MemoryAdapter | undefined {
|
||||
return adapters.get(name);
|
||||
}
|
||||
|
||||
export function getAvailableAdapters(): MemoryAdapter[] {
|
||||
return Array.from(adapters.values()).filter(a => a.available);
|
||||
}
|
||||
|
||||
export function getDefaultAdapter(): MemoryAdapter | undefined {
|
||||
// Prefer Memora if available, otherwise first available
|
||||
return adapters.get('memora') ?? getAvailableAdapters()[0];
|
||||
}
|
||||
384
v2/src/lib/components/Workspace/FilesTab.svelte
Normal file
384
v2/src/lib/components/Workspace/FilesTab.svelte
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
<script lang="ts">
|
||||
import { listDirectoryChildren, readFileContent, type DirEntry, type FileContent } from '../../adapters/files-bridge';
|
||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
let { cwd }: Props = $props();
|
||||
|
||||
// Tree state: expanded dirs and their children
|
||||
interface TreeNode extends DirEntry {
|
||||
children?: TreeNode[];
|
||||
loading?: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
let roots = $state<TreeNode[]>([]);
|
||||
let expandedPaths = $state<Set<string>>(new Set());
|
||||
let selectedPath = $state<string | null>(null);
|
||||
let fileContent = $state<FileContent | null>(null);
|
||||
let fileLoading = $state(false);
|
||||
let highlighterReady = $state(false);
|
||||
|
||||
// Load root directory
|
||||
$effect(() => {
|
||||
const dir = cwd;
|
||||
loadDirectory(dir).then(entries => {
|
||||
roots = entries.map(e => ({ ...e, depth: 0 }));
|
||||
});
|
||||
getHighlighter().then(() => { highlighterReady = true; });
|
||||
});
|
||||
|
||||
async function loadDirectory(path: string): Promise<DirEntry[]> {
|
||||
try {
|
||||
return await listDirectoryChildren(path);
|
||||
} catch (e) {
|
||||
console.warn('Failed to list directory:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDir(node: TreeNode) {
|
||||
const path = node.path;
|
||||
if (expandedPaths.has(path)) {
|
||||
const next = new Set(expandedPaths);
|
||||
next.delete(path);
|
||||
expandedPaths = next;
|
||||
} else {
|
||||
// Load children if not yet loaded
|
||||
if (!node.children) {
|
||||
node.loading = true;
|
||||
const entries = await loadDirectory(path);
|
||||
node.children = entries.map(e => ({ ...e, depth: node.depth + 1 }));
|
||||
node.loading = false;
|
||||
}
|
||||
expandedPaths = new Set([...expandedPaths, path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(node: TreeNode) {
|
||||
if (node.is_dir) {
|
||||
toggleDir(node);
|
||||
return;
|
||||
}
|
||||
selectedPath = node.path;
|
||||
fileLoading = true;
|
||||
try {
|
||||
fileContent = await readFileContent(node.path);
|
||||
} catch (e) {
|
||||
fileContent = { type: 'Binary', message: `Error: ${e}` };
|
||||
} finally {
|
||||
fileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenTree(nodes: TreeNode[]): TreeNode[] {
|
||||
const result: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.is_dir && expandedPaths.has(node.path) && node.children) {
|
||||
result.push(...flattenTree(node.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let flatNodes = $derived(flattenTree(roots));
|
||||
|
||||
function fileIcon(node: TreeNode): string {
|
||||
if (node.is_dir) return expandedPaths.has(node.path) ? '📂' : '📁';
|
||||
const ext = node.ext;
|
||||
if (['ts', 'tsx'].includes(ext)) return '🟦';
|
||||
if (['js', 'jsx', 'mjs'].includes(ext)) return '🟨';
|
||||
if (ext === 'rs') return '🦀';
|
||||
if (ext === 'py') return '🐍';
|
||||
if (ext === 'svelte') return '🟧';
|
||||
if (['md', 'markdown'].includes(ext)) return '📝';
|
||||
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '⚙️';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico'].includes(ext)) return '🖼️';
|
||||
if (ext === 'pdf') return '📄';
|
||||
if (['css', 'scss', 'less'].includes(ext)) return '🎨';
|
||||
if (['html', 'htm'].includes(ext)) return '🌐';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function renderHighlighted(content: string, lang: string): string {
|
||||
if (!highlighterReady || lang === 'text' || lang === 'csv') {
|
||||
return `<pre><code>${escapeHtml(content)}</code></pre>`;
|
||||
}
|
||||
const highlighted = highlightCode(content, lang);
|
||||
if (highlighted !== escapeHtml(content)) return highlighted;
|
||||
return `<pre><code>${escapeHtml(content)}</code></pre>`;
|
||||
}
|
||||
|
||||
function isImageExt(path: string): boolean {
|
||||
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="files-tab">
|
||||
<aside class="tree-sidebar">
|
||||
<div class="tree-header">
|
||||
<span class="tree-title">Explorer</span>
|
||||
</div>
|
||||
<div class="tree-list">
|
||||
{#each flatNodes as node (node.path)}
|
||||
<button
|
||||
class="tree-row"
|
||||
class:selected={selectedPath === node.path}
|
||||
class:dir={node.is_dir}
|
||||
style="padding-left: {0.5 + node.depth * 1}rem"
|
||||
onclick={() => selectFile(node)}
|
||||
>
|
||||
<span class="tree-icon">{fileIcon(node)}</span>
|
||||
<span class="tree-name">{node.name}</span>
|
||||
{#if !node.is_dir}
|
||||
<span class="tree-size">{formatSize(node.size)}</span>
|
||||
{/if}
|
||||
{#if node.loading}
|
||||
<span class="tree-loading">…</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if flatNodes.length === 0}
|
||||
<div class="tree-empty">No files</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="file-viewer">
|
||||
{#if fileLoading}
|
||||
<div class="viewer-state">Loading…</div>
|
||||
{:else if !selectedPath}
|
||||
<div class="viewer-state">Select a file to view</div>
|
||||
{:else if fileContent?.type === 'TooLarge'}
|
||||
<div class="viewer-state">
|
||||
<span class="viewer-warning">File too large</span>
|
||||
<span class="viewer-detail">{formatSize(fileContent.size)}</span>
|
||||
</div>
|
||||
{:else if fileContent?.type === 'Binary'}
|
||||
{#if isImageExt(selectedPath)}
|
||||
<div class="viewer-image">
|
||||
<img src={convertFileSrc(selectedPath)} alt={selectedPath.split('/').pop()} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="viewer-state">{fileContent.message}</div>
|
||||
{/if}
|
||||
{:else if fileContent?.type === 'Text'}
|
||||
<div class="viewer-code">
|
||||
{#if fileContent.lang === 'csv'}
|
||||
<pre class="csv-content"><code>{fileContent.content}</code></pre>
|
||||
{:else}
|
||||
{@html renderHighlighted(fileContent.content, fileContent.lang)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedPath}
|
||||
<div class="viewer-path">{selectedPath}</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.files-tab {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree-sidebar {
|
||||
width: 14rem;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
font-size: 0.675rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.7rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 0.1s;
|
||||
padding-right: 0.375rem;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.tree-row.selected {
|
||||
background: color-mix(in srgb, var(--accent, var(--ctp-blue)) 15%, transparent);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.tree-row.dir {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
font-size: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-size {
|
||||
font-size: 0.575rem;
|
||||
color: var(--ctp-overlay0);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.7rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--ctp-base);
|
||||
}
|
||||
|
||||
.viewer-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 0.5rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.viewer-warning {
|
||||
color: var(--ctp-yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewer-detail {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.viewer-code {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.viewer-code :global(pre) {
|
||||
margin: 0;
|
||||
font-family: var(--term-font-family, 'JetBrains Mono', monospace);
|
||||
font-size: 0.775rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.viewer-code :global(code) {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.viewer-code :global(.shiki) {
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.csv-content {
|
||||
font-family: var(--term-font-family, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.viewer-image {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.viewer-image img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.viewer-path {
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-overlay0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
375
v2/src/lib/components/Workspace/MemoriesTab.svelte
Normal file
375
v2/src/lib/components/Workspace/MemoriesTab.svelte
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<script lang="ts">
|
||||
import { getDefaultAdapter, getAvailableAdapters, type MemoryAdapter, type MemoryNode } from '../../adapters/memory-adapter';
|
||||
|
||||
let adapter = $state<MemoryAdapter | undefined>(undefined);
|
||||
let adapterName = $state('');
|
||||
let nodes = $state<MemoryNode[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let total = $state(0);
|
||||
let selectedNode = $state<MemoryNode | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
adapter = getDefaultAdapter();
|
||||
adapterName = adapter?.name ?? '';
|
||||
if (adapter) {
|
||||
loadNodes();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadNodes() {
|
||||
if (!adapter) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await adapter.list({ limit: 50 });
|
||||
nodes = result.nodes;
|
||||
total = result.total;
|
||||
} catch (e) {
|
||||
error = `Failed to load: ${e}`;
|
||||
nodes = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!adapter || !searchQuery.trim()) {
|
||||
if (adapter) loadNodes();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await adapter.search(searchQuery.trim(), { limit: 50 });
|
||||
nodes = result.nodes;
|
||||
total = result.total;
|
||||
} catch (e) {
|
||||
error = `Search failed: ${e}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(node: MemoryNode) {
|
||||
selectedNode = selectedNode?.id === node.id ? null : node;
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
selectedNode = null;
|
||||
loadNodes();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="memories-tab">
|
||||
{#if !adapter}
|
||||
<div class="no-adapter">
|
||||
<div class="no-adapter-icon">
|
||||
<svg width="24" height="24" 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"></circle>
|
||||
<path d="M12 8v4m0 4h.01"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="no-adapter-title">No memory adapter configured</p>
|
||||
<p class="no-adapter-hint">Register a memory adapter (e.g. Memora) to browse knowledge here.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mem-header">
|
||||
<h3>{adapterName}</h3>
|
||||
<span class="mem-count">{total} memories</span>
|
||||
<div class="mem-adapters">
|
||||
{#each getAvailableAdapters() as a (a.name)}
|
||||
<button
|
||||
class="adapter-btn"
|
||||
class:active={a.name === adapterName}
|
||||
onclick={() => { adapter = a; adapterName = a.name; loadNodes(); }}
|
||||
>{a.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mem-search">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search memories…"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="clear-btn" onclick={clearSearch}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mem-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="mem-list">
|
||||
{#if loading}
|
||||
<div class="mem-state">Loading…</div>
|
||||
{:else if nodes.length === 0}
|
||||
<div class="mem-state">No memories found</div>
|
||||
{:else}
|
||||
{#each nodes as node (node.id)}
|
||||
<button class="mem-card" class:expanded={selectedNode?.id === node.id} onclick={() => selectNode(node)}>
|
||||
<div class="mem-card-header">
|
||||
<span class="mem-id">#{node.id}</span>
|
||||
<div class="mem-tags">
|
||||
{#each node.tags.slice(0, 4) as tag}
|
||||
<span class="mem-tag">{tag}</span>
|
||||
{/each}
|
||||
{#if node.tags.length > 4}
|
||||
<span class="mem-tag-more">+{node.tags.length - 4}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mem-card-content" class:truncated={selectedNode?.id !== node.id}>
|
||||
{node.content}
|
||||
</div>
|
||||
{#if selectedNode?.id === node.id && node.metadata}
|
||||
<div class="mem-card-meta">
|
||||
<pre>{JSON.stringify(node.metadata, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.memories-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-adapter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-adapter-icon {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.no-adapter-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-adapter-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ctp-overlay0);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mem-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mem-header h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-blue);
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.mem-count {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.mem-adapters {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.adapter-btn {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.adapter-btn.active {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.mem-search {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--ctp-surface0);
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.mem-error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mem-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.mem-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mem-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.mem-card:hover {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.mem-card.expanded {
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.mem-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mem-id {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--term-font-family, monospace);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mem-tags {
|
||||
display: flex;
|
||||
gap: 0.1875rem;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mem-tag {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.1875rem;
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.mem-tag-more {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.mem-card-content {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
color: var(--ctp-subtext0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mem-card-content.truncated {
|
||||
max-height: 3em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.mem-card-meta {
|
||||
margin-top: 0.375rem;
|
||||
padding-top: 0.375rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.mem-card-meta pre {
|
||||
font-size: 0.6rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-overlay1);
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,9 @@
|
|||
import TeamAgentsPanel from './TeamAgentsPanel.svelte';
|
||||
import ProjectFiles from './ProjectFiles.svelte';
|
||||
import ContextPane from '../Context/ContextPane.svelte';
|
||||
import FilesTab from './FilesTab.svelte';
|
||||
import SshTab from './SshTab.svelte';
|
||||
import MemoriesTab from './MemoriesTab.svelte';
|
||||
import { getTerminalTabs } from '../../stores/workspace.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,12 +25,23 @@
|
|||
let mainSessionId = $state<string | null>(null);
|
||||
let terminalExpanded = $state(false);
|
||||
|
||||
type ProjectTab = 'claude' | 'files' | 'context';
|
||||
let activeTab = $state<ProjectTab>('claude');
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories';
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
|
||||
// PERSISTED-LAZY: track which tabs have been activated at least once
|
||||
let everActivated = $state<Record<string, boolean>>({});
|
||||
|
||||
let termTabs = $derived(getTerminalTabs(project.id));
|
||||
let termTabCount = $derived(termTabs.length);
|
||||
|
||||
/** Activate a tab — for lazy tabs, mark as ever-activated */
|
||||
function switchTab(tab: ProjectTab) {
|
||||
activeTab = tab;
|
||||
if (!everActivated[tab]) {
|
||||
everActivated = { ...everActivated, [tab]: true };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTerminal() {
|
||||
terminalExpanded = !terminalExpanded;
|
||||
}
|
||||
|
|
@ -48,38 +62,70 @@
|
|||
<div class="project-tabs">
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'claude'}
|
||||
onclick={() => activeTab = 'claude'}
|
||||
>Claude</button>
|
||||
class:active={activeTab === 'model'}
|
||||
onclick={() => switchTab('model')}
|
||||
>Model</button>
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'files'}
|
||||
onclick={() => activeTab = 'files'}
|
||||
>Files</button>
|
||||
class:active={activeTab === 'docs'}
|
||||
onclick={() => switchTab('docs')}
|
||||
>Docs</button>
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'context'}
|
||||
onclick={() => activeTab = 'context'}
|
||||
onclick={() => switchTab('context')}
|
||||
>Context</button>
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'files'}
|
||||
onclick={() => switchTab('files')}
|
||||
>Files</button>
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'ssh'}
|
||||
onclick={() => switchTab('ssh')}
|
||||
>SSH</button>
|
||||
<button
|
||||
class="ptab"
|
||||
class:active={activeTab === 'memories'}
|
||||
onclick={() => switchTab('memories')}
|
||||
>Memory</button>
|
||||
</div>
|
||||
|
||||
<div class="project-content-area">
|
||||
<!-- Use CSS display instead of {#if} to keep ClaudeSession alive across tab switches -->
|
||||
<div class="content-pane" style:display={activeTab === 'claude' ? 'flex' : 'none'}>
|
||||
<!-- PERSISTED-EAGER: always mounted, toggled via display -->
|
||||
<div class="content-pane" style:display={activeTab === 'model' ? 'flex' : 'none'}>
|
||||
<ClaudeSession {project} onsessionid={(id) => mainSessionId = id} />
|
||||
{#if mainSessionId}
|
||||
<TeamAgentsPanel {mainSessionId} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="content-pane" style:display={activeTab === 'files' ? 'flex' : 'none'}>
|
||||
<div class="content-pane" style:display={activeTab === 'docs' ? 'flex' : 'none'}>
|
||||
<ProjectFiles cwd={project.cwd} projectName={project.name} />
|
||||
</div>
|
||||
<div class="content-pane" style:display={activeTab === 'context' ? 'flex' : 'none'}>
|
||||
<ContextPane projectName={project.name} projectCwd={project.cwd} />
|
||||
</div>
|
||||
|
||||
<!-- PERSISTED-LAZY: mount on first activation, then toggle via display -->
|
||||
{#if everActivated['files']}
|
||||
<div class="content-pane" style:display={activeTab === 'files' ? 'flex' : 'none'}>
|
||||
<FilesTab cwd={project.cwd} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['ssh']}
|
||||
<div class="content-pane" style:display={activeTab === 'ssh' ? 'flex' : 'none'}>
|
||||
<SshTab projectId={project.id} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['memories']}
|
||||
<div class="content-pane" style:display={activeTab === 'memories' ? 'flex' : 'none'}>
|
||||
<MemoriesTab />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="terminal-section" style:display={activeTab === 'claude' ? 'flex' : 'none'}>
|
||||
<div class="terminal-section" style:display={activeTab === 'model' ? 'flex' : 'none'}>
|
||||
<button class="terminal-toggle" onclick={toggleTerminal}>
|
||||
<span class="toggle-chevron" class:expanded={terminalExpanded}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
|
|
|
|||
425
v2/src/lib/components/Workspace/SshTab.svelte
Normal file
425
v2/src/lib/components/Workspace/SshTab.svelte
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listSshSessions, saveSshSession, deleteSshSession, type SshSession } from '../../adapters/ssh-bridge';
|
||||
import { addTerminalTab } from '../../stores/workspace.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
|
||||
let sessions = $state<SshSession[]>([]);
|
||||
let loading = $state(true);
|
||||
let editing = $state<SshSession | null>(null);
|
||||
let showForm = $state(false);
|
||||
|
||||
// Form fields
|
||||
let formName = $state('');
|
||||
let formHost = $state('');
|
||||
let formPort = $state(22);
|
||||
let formUsername = $state('');
|
||||
let formKeyFile = $state('');
|
||||
let formFolder = $state('');
|
||||
|
||||
onMount(loadSessions);
|
||||
|
||||
async function loadSessions() {
|
||||
loading = true;
|
||||
try {
|
||||
sessions = await listSshSessions();
|
||||
} catch (e) {
|
||||
console.warn('Failed to load SSH sessions:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formName = '';
|
||||
formHost = '';
|
||||
formPort = 22;
|
||||
formUsername = '';
|
||||
formKeyFile = '';
|
||||
formFolder = '';
|
||||
editing = null;
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function editSession(session: SshSession) {
|
||||
formName = session.name;
|
||||
formHost = session.host;
|
||||
formPort = session.port;
|
||||
formUsername = session.username;
|
||||
formKeyFile = session.key_file;
|
||||
formFolder = session.folder;
|
||||
editing = session;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
resetForm();
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
if (!formName.trim() || !formHost.trim()) return;
|
||||
|
||||
const session: SshSession = {
|
||||
id: editing?.id ?? crypto.randomUUID(),
|
||||
name: formName.trim(),
|
||||
host: formHost.trim(),
|
||||
port: formPort,
|
||||
username: formUsername.trim() || 'root',
|
||||
key_file: formKeyFile.trim(),
|
||||
folder: formFolder.trim(),
|
||||
color: editing?.color ?? '',
|
||||
created_at: editing?.created_at ?? Date.now(),
|
||||
last_used_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await saveSshSession(session);
|
||||
await loadSessions();
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
console.warn('Failed to save SSH session:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSession(id: string) {
|
||||
try {
|
||||
await deleteSshSession(id);
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete SSH session:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function launchSession(session: SshSession) {
|
||||
addTerminalTab(projectId, {
|
||||
id: `ssh-${session.id}-${Date.now()}`,
|
||||
title: `SSH: ${session.name}`,
|
||||
type: 'ssh',
|
||||
sshSessionId: session.id,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ssh-tab">
|
||||
<div class="ssh-header">
|
||||
<h3>SSH Connections</h3>
|
||||
<button class="add-btn" onclick={startNew}>+ New</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="ssh-form">
|
||||
<div class="form-title">{editing ? 'Edit Connection' : 'New Connection'}</div>
|
||||
<div class="form-grid">
|
||||
<label class="form-label">
|
||||
<span>Name</span>
|
||||
<input type="text" bind:value={formName} placeholder="My Server" />
|
||||
</label>
|
||||
<label class="form-label">
|
||||
<span>Host</span>
|
||||
<input type="text" bind:value={formHost} placeholder="192.168.1.100" />
|
||||
</label>
|
||||
<label class="form-label">
|
||||
<span>Port</span>
|
||||
<input type="number" bind:value={formPort} min="1" max="65535" />
|
||||
</label>
|
||||
<label class="form-label">
|
||||
<span>Username</span>
|
||||
<input type="text" bind:value={formUsername} placeholder="root" />
|
||||
</label>
|
||||
<label class="form-label">
|
||||
<span>Key File</span>
|
||||
<input type="text" bind:value={formKeyFile} placeholder="~/.ssh/id_ed25519" />
|
||||
</label>
|
||||
<label class="form-label">
|
||||
<span>Remote Folder</span>
|
||||
<input type="text" bind:value={formFolder} placeholder="/home/user" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-cancel" onclick={resetForm}>Cancel</button>
|
||||
<button class="btn-save" onclick={saveForm} disabled={!formName.trim() || !formHost.trim()}>
|
||||
{editing ? 'Update' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ssh-list">
|
||||
{#if loading}
|
||||
<div class="ssh-empty">Loading…</div>
|
||||
{:else if sessions.length === 0 && !showForm}
|
||||
<div class="ssh-empty">
|
||||
<p>No SSH connections configured.</p>
|
||||
<p>Add a connection to launch it as a terminal in the Model tab.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each sessions as session (session.id)}
|
||||
<div class="ssh-card">
|
||||
<div class="ssh-card-info">
|
||||
<span class="ssh-card-name">{session.name}</span>
|
||||
<span class="ssh-card-detail">{session.username}@{session.host}:{session.port}</span>
|
||||
{#if session.folder}
|
||||
<span class="ssh-card-folder">{session.folder}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ssh-card-actions">
|
||||
<button class="ssh-btn launch" onclick={() => launchSession(session)} title="Launch in terminal">
|
||||
▶
|
||||
</button>
|
||||
<button class="ssh-btn edit" onclick={() => editSession(session)} title="Edit">
|
||||
✎
|
||||
</button>
|
||||
<button class="ssh-btn delete" onclick={() => removeSession(session.id)} title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ssh-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ssh-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ssh-header h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-blue);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: var(--ctp-surface0);
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.ssh-form {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 0.725rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext1);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.form-label span {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
color: var(--ctp-overlay1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.form-label input {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.725rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
}
|
||||
|
||||
.form-label input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-cancel, .btn-save {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ssh-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.ssh-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ssh-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ssh-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.375rem;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.ssh-card:hover {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.ssh-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.ssh-card-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.ssh-card-detail {
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.ssh-card-folder {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--term-font-family, monospace);
|
||||
}
|
||||
|
||||
.ssh-card-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ssh-btn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.ssh-btn.launch {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.ssh-btn.launch:hover {
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
}
|
||||
|
||||
.ssh-btn.edit {
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.ssh-btn.edit:hover {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
}
|
||||
|
||||
.ssh-btn.delete {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.ssh-btn.delete:hover {
|
||||
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue