refactor(v3): project-scoped ContextPane with auto-registration

This commit is contained in:
Hibryda 2026-03-08 04:13:41 +01:00
parent 0f0ea3fb59
commit e37c85e294
5 changed files with 139 additions and 147 deletions

View file

@ -141,6 +141,24 @@ impl CtxDb {
Ok(())
}
/// Register a project in the ctx database (creates if not exists).
pub fn register_project(&self, name: &str, description: &str, work_dir: Option<&str>) -> Result<(), String> {
let db_path = Self::db_path();
if !db_path.exists() {
return Err("ctx database not found".to_string());
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {e}"))?;
conn.execute(
"INSERT OR IGNORE INTO sessions (name, description, work_dir) VALUES (?1, ?2, ?3)",
rusqlite::params![name, description, work_dir],
).map_err(|e| format!("Failed to register project: {e}"))?;
Ok(())
}
pub fn list_projects(&self) -> Result<Vec<CtxProject>, String> {
let lock = self.conn.lock().unwrap();
let conn = lock.as_ref().ok_or("ctx database not found")?;

View file

@ -187,6 +187,11 @@ fn ctx_init_db(state: State<'_, AppState>) -> Result<(), String> {
state.ctx_db.init_db()
}
#[tauri::command]
fn ctx_register_project(state: State<'_, AppState>, name: String, description: String, work_dir: Option<String>) -> Result<(), String> {
state.ctx_db.register_project(&name, &description, work_dir.as_deref())
}
#[tauri::command]
fn ctx_list_projects(state: State<'_, AppState>) -> Result<Vec<ctx::CtxProject>, String> {
state.ctx_db.list_projects()
@ -545,6 +550,7 @@ pub fn run() {
ssh_session_save,
ssh_session_delete,
ctx_init_db,
ctx_register_project,
ctx_list_projects,
ctx_get_context,
ctx_get_shared,

View file

@ -24,6 +24,10 @@ export async function ctxInitDb(): Promise<void> {
return invoke('ctx_init_db');
}
export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void> {
return invoke('ctx_register_project', { name, description, workDir: workDir ?? null });
}
export async function ctxListProjects(): Promise<CtxProject[]> {
return invoke('ctx_list_projects');
}

View file

@ -2,24 +2,22 @@
import { onMount } from 'svelte';
import {
ctxInitDb,
ctxListProjects,
ctxRegisterProject,
ctxGetContext,
ctxGetShared,
ctxGetSummaries,
ctxSearch,
type CtxProject,
type CtxEntry,
type CtxSummary,
} from '../../adapters/ctx-bridge';
interface Props {
onExit?: () => void;
projectName: string;
projectCwd: string;
}
let { onExit }: Props = $props();
let { projectName, projectCwd }: Props = $props();
let projects = $state<CtxProject[]>([]);
let selectedProject = $state<string | null>(null);
let entries = $state<CtxEntry[]>([]);
let sharedEntries = $state<CtxEntry[]>([]);
let summaries = $state<CtxSummary[]>([]);
@ -30,15 +28,27 @@
let dbMissing = $state(false);
let initializing = $state(false);
async function loadData() {
async function loadProjectContext() {
loading = true;
try {
projects = await ctxListProjects();
sharedEntries = await ctxGetShared();
// Register project if not already (INSERT OR IGNORE)
await ctxRegisterProject(projectName, `BTerminal project: ${projectName}`, projectCwd);
const [ctx, shared, sums] = await Promise.all([
ctxGetContext(projectName),
ctxGetShared(),
ctxGetSummaries(projectName, 5),
]);
entries = ctx;
sharedEntries = shared;
summaries = sums;
error = '';
dbMissing = false;
} catch (e) {
error = `${e}`;
dbMissing = error.includes('not found');
} finally {
loading = false;
}
}
@ -46,7 +56,7 @@
initializing = true;
try {
await ctxInitDb();
await loadData();
await loadProjectContext();
} catch (e) {
error = `Failed to initialize database: ${e}`;
} finally {
@ -54,24 +64,6 @@
}
}
onMount(loadData);
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 = [];
@ -83,11 +75,13 @@
error = `Search failed: ${e}`;
}
}
onMount(loadProjectContext);
</script>
<div class="context-pane">
<div class="ctx-header">
<h3>Context Manager</h3>
<h3>{projectName}</h3>
<input
type="text"
class="search-input"
@ -124,69 +118,53 @@
</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>
{#if !error}
<div class="ctx-body">
{#if loading}
<div class="loading">Loading...</div>
{:else 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>
<pre class="entry-value">{result.value}</pre>
{/each}
<button class="clear-btn" onclick={() => { searchResults = []; searchQuery = ''; }}>Clear search</button>
</div>
{:else}
{#if entries.length > 0}
<div class="section">
<h4>Project Context</h4>
{#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>
{/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>
{#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>
<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>
{/each}
</div>
{/if}
{#if summaries.length > 0}
<div class="section">
@ -201,13 +179,16 @@
{/each}
</div>
{/if}
{/if}
{#if loading}
<div class="loading">Loading...</div>
{#if entries.length === 0 && sharedEntries.length === 0 && summaries.length === 0}
<div class="empty-state">
<p class="empty">No context stored yet.</p>
<p class="empty">Use <code>ctx set {projectName} &lt;key&gt; &lt;value&gt;</code> to add context entries.</p>
</div>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
</div>
<style>
@ -231,9 +212,10 @@
}
.ctx-header h3 {
font-size: 13px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
color: var(--ctp-blue);
}
.search-input {
@ -312,10 +294,6 @@
padding: 0.5rem 0.75rem;
}
.project-list {
margin-bottom: 0.75rem;
}
h4 {
font-size: 0.7rem;
font-weight: 600;
@ -325,89 +303,75 @@
margin-bottom: 0.375rem;
}
.project-btn {
display: flex;
flex-direction: column;
width: 100%;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
padding: 0.375rem 0.5rem;
margin-bottom: 0.25rem;
cursor: pointer;
text-align: left;
color: var(--ctp-text);
}
.project-btn:hover { border-color: var(--ctp-blue); }
.project-btn.active {
border-color: var(--ctp-blue);
background: color-mix(in srgb, var(--ctp-blue) 10%, var(--ctp-surface0));
}
.project-name {
font-weight: 600;
font-size: 12px;
}
.project-desc {
font-size: 10px;
color: var(--ctp-overlay0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section {
margin-bottom: 16px;
margin-bottom: 1rem;
}
.entry {
background: var(--ctp-surface0);
border-radius: 0.25rem;
padding: 6px 8px;
margin-bottom: 4px;
padding: 0.375rem 0.5rem;
margin-bottom: 0.25rem;
}
.entry-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.entry-project {
font-size: 10px;
font-size: 0.625rem;
color: var(--ctp-blue);
font-weight: 600;
}
.entry-key {
font-size: 11px;
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-green);
}
.entry-date {
font-size: 9px;
font-size: 0.5625rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.entry-value {
font-size: 11px;
font-size: 0.6875rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--ctp-subtext0);
max-height: 200px;
max-height: 12.5rem;
overflow-y: auto;
margin: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
gap: 0.25rem;
}
.empty {
color: var(--ctp-overlay0);
font-size: 11px;
font-size: 0.6875rem;
font-style: italic;
margin: 0;
}
.empty code {
background: var(--ctp-surface0);
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-green);
font-style: normal;
}
.clear-btn {
@ -415,18 +379,18 @@
border: 1px solid var(--ctp-surface0);
color: var(--ctp-subtext0);
border-radius: 0.25rem;
padding: 4px 10px;
font-size: 11px;
padding: 0.25rem 0.625rem;
font-size: 0.6875rem;
cursor: pointer;
margin-top: 4px;
margin-top: 0.25rem;
}
.clear-btn:hover { color: var(--ctp-text); }
.loading {
color: var(--ctp-overlay0);
font-size: 12px;
font-size: 0.75rem;
text-align: center;
padding: 16px;
padding: 1rem;
}
</style>

View file

@ -77,7 +77,7 @@
</div>
{:else if activeTab === 'context'}
<div class="content-pane">
<ContextPane onExit={() => {}} />
<ContextPane projectName={project.name} projectCwd={project.cwd} />
</div>
{/if}
</div>