feat: Sprint 0 — settings panel infrastructure
SettingsPanel.svelte: VS Code-style shell with sidebar categories, search bar (fuzzy on registry metadata), keyboard navigation (arrows, Escape), deep-link to setting anchors. settings-registry.ts: static metadata for 35+ settings with key, label, description, category, anchorId, keywords, scopeable, pro flags. Supports fuzzy search and category filtering. settings-scope.svelte.ts: centralized scope resolution store. scopedGet/scopedSet resolve Global→Project cascade. Override chain for ScopeCascade display. Cache invalidation on project switch. settings/categories/ directory ready for Sprint 1 extraction.
This commit is contained in:
parent
6ca168e336
commit
244d5e3938
3 changed files with 490 additions and 0 deletions
294
src/lib/settings/SettingsPanel.svelte
Normal file
294
src/lib/settings/SettingsPanel.svelte
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<script lang="ts">
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Settings Panel — VS Code-style sidebar + content layout with search.
|
||||
import { onMount } from 'svelte';
|
||||
import { SETTINGS_REGISTRY, type SettingsCategory, type SettingEntry } from './settings-registry';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
}
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
const CATEGORIES: { id: SettingsCategory; label: string; icon: string }[] = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||
{ id: 'agents', label: 'Agents', icon: '🤖' },
|
||||
{ id: 'security', label: 'Security', icon: '🛡' },
|
||||
{ id: 'projects', label: 'Projects', icon: '📁' },
|
||||
{ id: 'orchestration', label: 'Orchestration', icon: '🔗' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '⚡' },
|
||||
];
|
||||
|
||||
let activeCategory = $state<SettingsCategory>('appearance');
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $derived(searchQuery.length >= 2
|
||||
? SETTINGS_REGISTRY.filter(s => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return s.label.toLowerCase().includes(q)
|
||||
|| s.description.toLowerCase().includes(q)
|
||||
|| s.keywords.some(k => k.includes(q));
|
||||
})
|
||||
: []
|
||||
);
|
||||
let showSearch = $derived(searchQuery.length >= 2);
|
||||
let sidebarRef: HTMLElement | undefined = $state();
|
||||
|
||||
function selectCategory(id: SettingsCategory) {
|
||||
activeCategory = id;
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function navigateToResult(entry: SettingEntry) {
|
||||
activeCategory = entry.category;
|
||||
searchQuery = '';
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(entry.anchorId);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
|
||||
function handleSidebarKeydown(e: KeyboardEvent) {
|
||||
const idx = CATEGORIES.findIndex(c => c.id === activeCategory);
|
||||
if (e.key === 'ArrowDown' && idx < CATEGORIES.length - 1) {
|
||||
e.preventDefault();
|
||||
selectCategory(CATEGORIES[idx + 1].id);
|
||||
} else if (e.key === 'ArrowUp' && idx > 0) {
|
||||
e.preventDefault();
|
||||
selectCategory(CATEGORIES[idx - 1].id);
|
||||
} else if (e.key === 'Escape' && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchQuery) {
|
||||
searchQuery = '';
|
||||
} else if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} else if (e.key === 'Enter' && searchResults.length > 0) {
|
||||
navigateToResult(searchResults[0]);
|
||||
}
|
||||
}
|
||||
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
onMount(() => { searchInput?.focus(); });
|
||||
</script>
|
||||
|
||||
<div class="settings-panel" onkeydown={handleSidebarKeydown}>
|
||||
<div class="settings-header">
|
||||
<span class="settings-title">Settings</span>
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
class="settings-search"
|
||||
type="text"
|
||||
placeholder="Search settings..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={handleSearchKeydown}
|
||||
aria-label="Search settings"
|
||||
/>
|
||||
{#if onClose}
|
||||
<button class="settings-close" onclick={onClose} aria-label="Close settings">x</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showSearch}
|
||||
<div class="search-results">
|
||||
{#each searchResults.slice(0, 10) as result}
|
||||
<button class="search-result" onclick={() => navigateToResult(result)}>
|
||||
<span class="sr-label">{result.label}</span>
|
||||
{#if result.pro}<span class="sr-pro">Pro</span>{/if}
|
||||
<span class="sr-cat">{result.category}</span>
|
||||
<span class="sr-desc">{result.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if searchResults.length === 0}
|
||||
<div class="search-empty">No settings match "{searchQuery}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="settings-body">
|
||||
<nav class="settings-sidebar" bind:this={sidebarRef} role="tablist" aria-label="Settings categories">
|
||||
{#each CATEGORIES as cat}
|
||||
<button
|
||||
class="sidebar-item"
|
||||
class:active={activeCategory === cat.id}
|
||||
onclick={() => selectCategory(cat.id)}
|
||||
role="tab"
|
||||
aria-selected={activeCategory === cat.id}
|
||||
>
|
||||
<span class="sidebar-icon">{cat.icon}</span>
|
||||
<span class="sidebar-label">{cat.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="settings-content" role="tabpanel">
|
||||
<!-- Category components will be rendered here via dynamic import or {#if} -->
|
||||
<div class="category-placeholder">
|
||||
<h2>{CATEGORIES.find(c => c.id === activeCategory)?.label}</h2>
|
||||
<p>Settings for {activeCategory} will be rendered here.</p>
|
||||
<p class="setting-count">{SETTINGS_REGISTRY.filter(s => s.category === activeCategory).length} settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ctp-subtext0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-search {
|
||||
flex: 1;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
.settings-search:focus {
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
.settings-search::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.settings-close:hover { color: var(--ctp-text); }
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.search-result {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.search-result:hover { background: var(--ctp-surface0); }
|
||||
.sr-label { font-weight: 600; }
|
||||
.sr-pro {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
background: var(--ctp-peach);
|
||||
color: var(--ctp-base);
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sr-cat {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-overlay0);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sr-desc {
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
.search-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 10rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
color: var(--ctp-blue);
|
||||
border-left-color: var(--ctp-blue);
|
||||
background: color-mix(in srgb, var(--ctp-blue) 8%, transparent);
|
||||
}
|
||||
.sidebar-icon { font-size: 1rem; }
|
||||
.sidebar-label { font-weight: 500; }
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
.category-placeholder {
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
.category-placeholder h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.setting-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
89
src/lib/settings/settings-registry.ts
Normal file
89
src/lib/settings/settings-registry.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Settings Registry — static metadata for all configurable settings.
|
||||
// Used by SettingsSearch for fuzzy search + deep-linking.
|
||||
// Every setting component MUST have a corresponding entry here.
|
||||
// CI lint rule enforces this (see .github/workflows/).
|
||||
|
||||
export type SettingsCategory =
|
||||
| 'appearance'
|
||||
| 'agents'
|
||||
| 'security'
|
||||
| 'projects'
|
||||
| 'orchestration'
|
||||
| 'advanced';
|
||||
|
||||
export interface SettingEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: SettingsCategory;
|
||||
/** HTML element ID for scroll-to-anchor */
|
||||
anchorId: string;
|
||||
/** Keywords for fuzzy search (beyond label + description) */
|
||||
keywords: string[];
|
||||
/** Whether this setting can be overridden per-project */
|
||||
scopeable: boolean;
|
||||
/** Whether this requires Pro edition */
|
||||
pro: boolean;
|
||||
}
|
||||
|
||||
export const SETTINGS_REGISTRY: SettingEntry[] = [
|
||||
// --- Appearance ---
|
||||
{ key: 'theme', label: 'Theme', description: 'Color theme for the entire application', category: 'appearance', anchorId: 'setting-theme', keywords: ['color', 'dark', 'light', 'catppuccin', 'mocha'], scopeable: false, pro: false },
|
||||
{ key: 'ui_font_family', label: 'UI Font', description: 'Font family for interface elements', category: 'appearance', anchorId: 'setting-ui-font', keywords: ['font', 'sans-serif', 'text'], scopeable: false, pro: false },
|
||||
{ key: 'ui_font_size', label: 'UI Font Size', description: 'Font size for interface elements in pixels', category: 'appearance', anchorId: 'setting-ui-font-size', keywords: ['font', 'size', 'text'], scopeable: false, pro: false },
|
||||
{ key: 'term_font_family', label: 'Terminal Font', description: 'Font family for terminal and code display', category: 'appearance', anchorId: 'setting-term-font', keywords: ['font', 'monospace', 'terminal', 'code'], scopeable: false, pro: false },
|
||||
{ key: 'term_font_size', label: 'Terminal Font Size', description: 'Font size for terminal display in pixels', category: 'appearance', anchorId: 'setting-term-font-size', keywords: ['font', 'size', 'terminal'], scopeable: false, pro: false },
|
||||
{ key: 'term_cursor_style', label: 'Cursor Style', description: 'Terminal cursor shape: block, line, or underline', category: 'appearance', anchorId: 'setting-cursor-style', keywords: ['cursor', 'terminal', 'block', 'line', 'underline'], scopeable: false, pro: false },
|
||||
{ key: 'term_cursor_blink', label: 'Cursor Blink', description: 'Whether the terminal cursor blinks', category: 'appearance', anchorId: 'setting-cursor-blink', keywords: ['cursor', 'blink', 'terminal'], scopeable: false, pro: false },
|
||||
{ key: 'term_scrollback', label: 'Scrollback Lines', description: 'Number of lines to keep in terminal scrollback buffer', category: 'appearance', anchorId: 'setting-scrollback', keywords: ['scrollback', 'buffer', 'history', 'terminal'], scopeable: false, pro: false },
|
||||
|
||||
// --- Agents ---
|
||||
{ key: 'default_provider', label: 'Default Provider', description: 'Default AI provider for new agent sessions', category: 'agents', anchorId: 'setting-default-provider', keywords: ['provider', 'claude', 'codex', 'ollama', 'aider'], scopeable: true, pro: false },
|
||||
{ key: 'default_model', label: 'Default Model', description: 'Default model for new agent sessions', category: 'agents', anchorId: 'setting-default-model', keywords: ['model', 'opus', 'sonnet', 'haiku', 'gpt'], scopeable: true, pro: false },
|
||||
{ key: 'default_permission_mode', label: 'Permission Mode', description: 'Default permission mode for agent sessions', category: 'agents', anchorId: 'setting-permission-mode', keywords: ['permission', 'bypass', 'ask', 'plan'], scopeable: true, pro: false },
|
||||
{ key: 'system_prompt_template', label: 'System Prompt Template', description: 'Default system prompt prepended to agent sessions', category: 'agents', anchorId: 'setting-system-prompt', keywords: ['prompt', 'system', 'template', 'instructions'], scopeable: true, pro: false },
|
||||
{ key: 'context_pressure_warn', label: 'Context Pressure Warning', description: 'Percentage of context window that triggers a warning badge', category: 'agents', anchorId: 'setting-context-warn', keywords: ['context', 'pressure', 'warning', 'tokens'], scopeable: false, pro: false },
|
||||
{ key: 'context_pressure_critical', label: 'Context Pressure Critical', description: 'Percentage of context window that triggers a critical badge', category: 'agents', anchorId: 'setting-context-critical', keywords: ['context', 'pressure', 'critical', 'tokens'], scopeable: false, pro: false },
|
||||
{ key: 'budget_monthly_tokens', label: 'Monthly Token Budget', description: 'Maximum tokens per month for this project (Pro)', category: 'agents', anchorId: 'setting-budget', keywords: ['budget', 'tokens', 'cost', 'limit'], scopeable: true, pro: true },
|
||||
{ key: 'router_profile', label: 'Model Router Profile', description: 'Smart model routing profile: cost_saver, balanced, or quality_first (Pro)', category: 'agents', anchorId: 'setting-router', keywords: ['router', 'model', 'cost', 'quality', 'balanced'], scopeable: true, pro: true },
|
||||
|
||||
// --- Security ---
|
||||
{ key: 'branch_policies', label: 'Branch Policies', description: 'Protected branch patterns that block agent sessions', category: 'security', anchorId: 'setting-branch-policies', keywords: ['branch', 'policy', 'protect', 'main', 'release'], scopeable: false, pro: true },
|
||||
{ key: 'telemetry_enabled', label: 'Telemetry', description: 'Send anonymous usage telemetry via OpenTelemetry', category: 'security', anchorId: 'setting-telemetry', keywords: ['telemetry', 'privacy', 'otel', 'tracking'], scopeable: false, pro: false },
|
||||
{ key: 'data_retention_days', label: 'Data Retention', description: 'Days to keep session data before auto-cleanup', category: 'security', anchorId: 'setting-retention', keywords: ['retention', 'cleanup', 'privacy', 'data'], scopeable: false, pro: false },
|
||||
|
||||
// --- Orchestration ---
|
||||
{ key: 'stall_threshold_min', label: 'Stall Threshold', description: 'Minutes of inactivity before an agent is marked stalled', category: 'orchestration', anchorId: 'setting-stall-threshold', keywords: ['stall', 'timeout', 'inactive', 'health'], scopeable: true, pro: false },
|
||||
{ key: 'anchor_budget_scale', label: 'Anchor Budget Scale', description: 'Token budget for session anchors: small, medium, large, or full', category: 'orchestration', anchorId: 'setting-anchor-budget', keywords: ['anchor', 'budget', 'compaction', 'context'], scopeable: true, pro: false },
|
||||
{ key: 'auto_anchor', label: 'Auto-Anchor', description: 'Automatically create anchors on first context compaction', category: 'orchestration', anchorId: 'setting-auto-anchor', keywords: ['anchor', 'auto', 'compaction'], scopeable: true, pro: false },
|
||||
{ key: 'wake_strategy', label: 'Wake Strategy', description: 'Auto-wake strategy for idle manager agents', category: 'orchestration', anchorId: 'setting-wake-strategy', keywords: ['wake', 'auto', 'strategy', 'persistent', 'on-demand', 'smart'], scopeable: true, pro: false },
|
||||
{ key: 'wake_threshold', label: 'Wake Threshold', description: 'Score threshold for smart wake strategy (0-1)', category: 'orchestration', anchorId: 'setting-wake-threshold', keywords: ['wake', 'threshold', 'score'], scopeable: true, pro: false },
|
||||
{ key: 'notification_desktop', label: 'Desktop Notifications', description: 'Show OS desktop notifications for agent events', category: 'orchestration', anchorId: 'setting-notif-desktop', keywords: ['notification', 'desktop', 'os', 'alert'], scopeable: false, pro: false },
|
||||
{ key: 'notification_types', label: 'Notification Types', description: 'Which event types trigger notifications', category: 'orchestration', anchorId: 'setting-notif-types', keywords: ['notification', 'types', 'complete', 'error', 'crash'], scopeable: false, pro: false },
|
||||
{ key: 'memory_ttl_days', label: 'Memory TTL', description: 'Days before agent memory fragments expire (Pro)', category: 'orchestration', anchorId: 'setting-memory-ttl', keywords: ['memory', 'ttl', 'expire', 'retention'], scopeable: true, pro: true },
|
||||
{ key: 'memory_auto_extract', label: 'Auto-Extract Memory', description: 'Automatically extract decisions and patterns from sessions (Pro)', category: 'orchestration', anchorId: 'setting-memory-extract', keywords: ['memory', 'extract', 'auto', 'decisions'], scopeable: true, pro: true },
|
||||
|
||||
// --- Advanced ---
|
||||
{ key: 'default_shell', label: 'Default Shell', description: 'Shell executable for new terminal sessions', category: 'advanced', anchorId: 'setting-default-shell', keywords: ['shell', 'bash', 'zsh', 'fish', 'terminal'], scopeable: false, pro: false },
|
||||
{ key: 'default_cwd', label: 'Default Working Directory', description: 'Starting directory for new terminal sessions', category: 'advanced', anchorId: 'setting-default-cwd', keywords: ['directory', 'cwd', 'working', 'path'], scopeable: false, pro: false },
|
||||
{ key: 'otlp_endpoint', label: 'OTLP Endpoint', description: 'OpenTelemetry collector endpoint for trace export', category: 'advanced', anchorId: 'setting-otlp', keywords: ['telemetry', 'otel', 'otlp', 'tempo', 'traces'], scopeable: false, pro: false },
|
||||
{ key: 'log_level', label: 'Log Level', description: 'Minimum log level for console output', category: 'advanced', anchorId: 'setting-log-level', keywords: ['log', 'debug', 'info', 'warn', 'error', 'trace'], scopeable: false, pro: false },
|
||||
{ key: 'plugin_auto_update', label: 'Plugin Auto-Update', description: 'Automatically check for plugin updates on startup', category: 'advanced', anchorId: 'setting-plugin-auto-update', keywords: ['plugin', 'update', 'auto', 'marketplace'], scopeable: false, pro: false },
|
||||
{ key: 'relay_urls', label: 'Relay URLs', description: 'Remote machine relay WebSocket endpoints', category: 'advanced', anchorId: 'setting-relay-urls', keywords: ['relay', 'remote', 'machine', 'websocket', 'multi-machine'], scopeable: false, pro: false },
|
||||
{ key: 'export_format', label: 'Export Format', description: 'Default format for session exports (markdown)', category: 'advanced', anchorId: 'setting-export-format', keywords: ['export', 'format', 'markdown', 'report'], scopeable: false, pro: true },
|
||||
];
|
||||
|
||||
/** Get all settings for a category. */
|
||||
export function getSettingsForCategory(category: SettingsCategory): SettingEntry[] {
|
||||
return SETTINGS_REGISTRY.filter(s => s.category === category);
|
||||
}
|
||||
|
||||
/** Get all scopeable settings. */
|
||||
export function getScopeableSettings(): SettingEntry[] {
|
||||
return SETTINGS_REGISTRY.filter(s => s.scopeable);
|
||||
}
|
||||
|
||||
/** Get all Pro-gated settings. */
|
||||
export function getProSettings(): SettingEntry[] {
|
||||
return SETTINGS_REGISTRY.filter(s => s.pro);
|
||||
}
|
||||
107
src/lib/stores/settings-scope.svelte.ts
Normal file
107
src/lib/stores/settings-scope.svelte.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Settings Scope Store — centralized resolution of per-project overrides.
|
||||
// Provides scoped get/set: reads project-level override if exists, falls back to global.
|
||||
// Single source of truth for which scope is active and how overrides cascade.
|
||||
|
||||
import { getSetting, setSetting } from '../adapters/settings-bridge';
|
||||
|
||||
type Scope = 'global' | 'project';
|
||||
|
||||
interface ScopeOverride {
|
||||
key: string;
|
||||
globalValue: string | null;
|
||||
projectValue: string | null;
|
||||
effectiveValue: string | null;
|
||||
source: Scope;
|
||||
}
|
||||
|
||||
let activeProjectId = $state<string | null>(null);
|
||||
let overrideCache = $state<Map<string, ScopeOverride>>(new Map());
|
||||
|
||||
/** Set the active project for scoped settings resolution. */
|
||||
export function setActiveProject(projectId: string | null) {
|
||||
activeProjectId = projectId;
|
||||
overrideCache = new Map();
|
||||
}
|
||||
|
||||
/** Get the currently active project ID. */
|
||||
export function getActiveProjectForSettings(): string | null {
|
||||
return activeProjectId;
|
||||
}
|
||||
|
||||
/** Get a setting value with scope resolution.
|
||||
* If activeProjectId is set and a project-specific override exists, return that.
|
||||
* Otherwise return the global value. */
|
||||
export async function scopedGet(key: string): Promise<string | null> {
|
||||
// Check cache first
|
||||
const cached = overrideCache.get(key);
|
||||
if (cached) return cached.effectiveValue;
|
||||
|
||||
const globalValue = await getSetting(key);
|
||||
|
||||
if (!activeProjectId) {
|
||||
overrideCache.set(key, { key, globalValue, projectValue: null, effectiveValue: globalValue, source: 'global' });
|
||||
return globalValue;
|
||||
}
|
||||
|
||||
const projectKey = `project:${activeProjectId}:${key}`;
|
||||
const projectValue = await getSetting(projectKey);
|
||||
|
||||
const effectiveValue = projectValue ?? globalValue;
|
||||
const source: Scope = projectValue !== null ? 'project' : 'global';
|
||||
|
||||
overrideCache.set(key, { key, globalValue, projectValue, effectiveValue, source });
|
||||
return effectiveValue;
|
||||
}
|
||||
|
||||
/** Set a setting value at the specified scope. */
|
||||
export async function scopedSet(key: string, value: string, scope: Scope = 'global'): Promise<void> {
|
||||
if (scope === 'project' && activeProjectId) {
|
||||
const projectKey = `project:${activeProjectId}:${key}`;
|
||||
await setSetting(projectKey, value);
|
||||
} else {
|
||||
await setSetting(key, value);
|
||||
}
|
||||
// Invalidate cache for this key
|
||||
overrideCache.delete(key);
|
||||
}
|
||||
|
||||
/** Remove a project-level override, reverting to global. */
|
||||
export async function removeOverride(key: string): Promise<void> {
|
||||
if (!activeProjectId) return;
|
||||
const projectKey = `project:${activeProjectId}:${key}`;
|
||||
await setSetting(projectKey, '');
|
||||
overrideCache.delete(key);
|
||||
}
|
||||
|
||||
/** Get the full override chain for a setting (for ScopeCascade display). */
|
||||
export async function getOverrideChain(key: string): Promise<ScopeOverride> {
|
||||
const cached = overrideCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const globalValue = await getSetting(key);
|
||||
let projectValue: string | null = null;
|
||||
|
||||
if (activeProjectId) {
|
||||
const projectKey = `project:${activeProjectId}:${key}`;
|
||||
projectValue = await getSetting(projectKey);
|
||||
}
|
||||
|
||||
const effectiveValue = projectValue ?? globalValue;
|
||||
const source: Scope = projectValue !== null ? 'project' : 'global';
|
||||
const override: ScopeOverride = { key, globalValue, projectValue, effectiveValue, source };
|
||||
overrideCache.set(key, override);
|
||||
return override;
|
||||
}
|
||||
|
||||
/** Check if a setting has a project-level override. */
|
||||
export function hasProjectOverride(key: string): boolean {
|
||||
const cached = overrideCache.get(key);
|
||||
return cached?.source === 'project';
|
||||
}
|
||||
|
||||
/** Clear the entire cache (call on project switch). */
|
||||
export function invalidateSettingsCache() {
|
||||
overrideCache = new Map();
|
||||
}
|
||||
|
||||
export type { Scope, ScopeOverride };
|
||||
Loading…
Add table
Add a link
Reference in a new issue