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(()) 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> { pub fn list_projects(&self) -> Result<Vec<CtxProject>, String> {
let lock = self.conn.lock().unwrap(); let lock = self.conn.lock().unwrap();
let conn = lock.as_ref().ok_or("ctx database not found")?; 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() 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] #[tauri::command]
fn ctx_list_projects(state: State<'_, AppState>) -> Result<Vec<ctx::CtxProject>, String> { fn ctx_list_projects(state: State<'_, AppState>) -> Result<Vec<ctx::CtxProject>, String> {
state.ctx_db.list_projects() state.ctx_db.list_projects()
@ -545,6 +550,7 @@ pub fn run() {
ssh_session_save, ssh_session_save,
ssh_session_delete, ssh_session_delete,
ctx_init_db, ctx_init_db,
ctx_register_project,
ctx_list_projects, ctx_list_projects,
ctx_get_context, ctx_get_context,
ctx_get_shared, ctx_get_shared,

View file

@ -24,6 +24,10 @@ export async function ctxInitDb(): Promise<void> {
return invoke('ctx_init_db'); 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[]> { export async function ctxListProjects(): Promise<CtxProject[]> {
return invoke('ctx_list_projects'); return invoke('ctx_list_projects');
} }

View file

@ -2,24 +2,22 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
ctxInitDb, ctxInitDb,
ctxListProjects, ctxRegisterProject,
ctxGetContext, ctxGetContext,
ctxGetShared, ctxGetShared,
ctxGetSummaries, ctxGetSummaries,
ctxSearch, ctxSearch,
type CtxProject,
type CtxEntry, type CtxEntry,
type CtxSummary, type CtxSummary,
} from '../../adapters/ctx-bridge'; } from '../../adapters/ctx-bridge';
interface Props { 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 entries = $state<CtxEntry[]>([]);
let sharedEntries = $state<CtxEntry[]>([]); let sharedEntries = $state<CtxEntry[]>([]);
let summaries = $state<CtxSummary[]>([]); let summaries = $state<CtxSummary[]>([]);
@ -30,15 +28,27 @@
let dbMissing = $state(false); let dbMissing = $state(false);
let initializing = $state(false); let initializing = $state(false);
async function loadData() { async function loadProjectContext() {
loading = true;
try { try {
projects = await ctxListProjects(); // Register project if not already (INSERT OR IGNORE)
sharedEntries = await ctxGetShared(); 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 = ''; error = '';
dbMissing = false; dbMissing = false;
} catch (e) { } catch (e) {
error = `${e}`; error = `${e}`;
dbMissing = error.includes('not found'); dbMissing = error.includes('not found');
} finally {
loading = false;
} }
} }
@ -46,7 +56,7 @@
initializing = true; initializing = true;
try { try {
await ctxInitDb(); await ctxInitDb();
await loadData(); await loadProjectContext();
} catch (e) { } catch (e) {
error = `Failed to initialize database: ${e}`; error = `Failed to initialize database: ${e}`;
} finally { } 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() { async function handleSearch() {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
searchResults = []; searchResults = [];
@ -83,11 +75,13 @@
error = `Search failed: ${e}`; error = `Search failed: ${e}`;
} }
} }
onMount(loadProjectContext);
</script> </script>
<div class="context-pane"> <div class="context-pane">
<div class="ctx-header"> <div class="ctx-header">
<h3>Context Manager</h3> <h3>{projectName}</h3>
<input <input
type="text" type="text"
class="search-input" class="search-input"
@ -124,8 +118,11 @@
</div> </div>
{/if} {/if}
{#if !error}
<div class="ctx-body"> <div class="ctx-body">
{#if searchResults.length > 0} {#if loading}
<div class="loading">Loading...</div>
{:else if searchResults.length > 0}
<div class="section"> <div class="section">
<h4>Search Results</h4> <h4>Search Results</h4>
{#each searchResults as result} {#each searchResults as result}
@ -140,22 +137,20 @@
<button class="clear-btn" onclick={() => { searchResults = []; searchQuery = ''; }}>Clear search</button> <button class="clear-btn" onclick={() => { searchResults = []; searchQuery = ''; }}>Clear search</button>
</div> </div>
{:else} {:else}
<div class="project-list"> {#if entries.length > 0}
<h4>Projects</h4> <div class="section">
{#if projects.length === 0} <h4>Project Context</h4>
<p class="empty">No projects registered. Use <code>ctx init</code> to add one.</p> {#each entries as entry}
{/if} <div class="entry">
{#each projects as project} <div class="entry-header">
<button <span class="entry-key">{entry.key}</span>
class="project-btn" <span class="entry-date">{entry.updated_at}</span>
class:active={selectedProject === project.name} </div>
onclick={() => selectProject(project.name)} <pre class="entry-value">{entry.value}</pre>
> </div>
<span class="project-name">{project.name}</span>
<span class="project-desc">{project.description}</span>
</button>
{/each} {/each}
</div> </div>
{/if}
{#if sharedEntries.length > 0} {#if sharedEntries.length > 0}
<div class="section"> <div class="section">
@ -171,23 +166,6 @@
</div> </div>
{/if} {/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} {#if summaries.length > 0}
<div class="section"> <div class="section">
<h4>Recent Sessions</h4> <h4>Recent Sessions</h4>
@ -201,13 +179,16 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{/if}
{#if loading} {#if entries.length === 0 && sharedEntries.length === 0 && summaries.length === 0}
<div class="loading">Loading...</div> <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} {/if}
</div> </div>
{/if}
</div> </div>
<style> <style>
@ -231,9 +212,10 @@
} }
.ctx-header h3 { .ctx-header h3 {
font-size: 13px; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
color: var(--ctp-blue);
} }
.search-input { .search-input {
@ -312,10 +294,6 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
} }
.project-list {
margin-bottom: 0.75rem;
}
h4 { h4 {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
@ -325,89 +303,75 @@
margin-bottom: 0.375rem; 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 { .section {
margin-bottom: 16px; margin-bottom: 1rem;
} }
.entry { .entry {
background: var(--ctp-surface0); background: var(--ctp-surface0);
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 6px 8px; padding: 0.375rem 0.5rem;
margin-bottom: 4px; margin-bottom: 0.25rem;
} }
.entry-header { .entry-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 0.5rem;
margin-bottom: 4px; margin-bottom: 0.25rem;
} }
.entry-project { .entry-project {
font-size: 10px; font-size: 0.625rem;
color: var(--ctp-blue); color: var(--ctp-blue);
font-weight: 600; font-weight: 600;
} }
.entry-key { .entry-key {
font-size: 11px; font-size: 0.6875rem;
font-weight: 600; font-weight: 600;
color: var(--ctp-green); color: var(--ctp-green);
} }
.entry-date { .entry-date {
font-size: 9px; font-size: 0.5625rem;
color: var(--ctp-overlay0); color: var(--ctp-overlay0);
margin-left: auto; margin-left: auto;
} }
.entry-value { .entry-value {
font-size: 11px; font-size: 0.6875rem;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
color: var(--ctp-subtext0); color: var(--ctp-subtext0);
max-height: 200px; max-height: 12.5rem;
overflow-y: auto; overflow-y: auto;
margin: 0; margin: 0;
} }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
gap: 0.25rem;
}
.empty { .empty {
color: var(--ctp-overlay0); color: var(--ctp-overlay0);
font-size: 11px; font-size: 0.6875rem;
font-style: italic; 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 { .clear-btn {
@ -415,18 +379,18 @@
border: 1px solid var(--ctp-surface0); border: 1px solid var(--ctp-surface0);
color: var(--ctp-subtext0); color: var(--ctp-subtext0);
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 4px 10px; padding: 0.25rem 0.625rem;
font-size: 11px; font-size: 0.6875rem;
cursor: pointer; cursor: pointer;
margin-top: 4px; margin-top: 0.25rem;
} }
.clear-btn:hover { color: var(--ctp-text); } .clear-btn:hover { color: var(--ctp-text); }
.loading { .loading {
color: var(--ctp-overlay0); color: var(--ctp-overlay0);
font-size: 12px; font-size: 0.75rem;
text-align: center; text-align: center;
padding: 16px; padding: 1rem;
} }
</style> </style>

View file

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