757 lines
28 KiB
TypeScript
757 lines
28 KiB
TypeScript
import React from 'react';
|
|
import { cn, getModifierLabel } from '@/lib/utils';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useAgentsStore } from '@/stores/useAgentsStore';
|
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
|
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
|
import {
|
|
RiAiAgentLine,
|
|
RiArrowLeftSLine,
|
|
RiBarChart2Line,
|
|
RiBookLine,
|
|
RiBookOpenLine,
|
|
RiChatAi3Line,
|
|
RiChatHistoryLine,
|
|
RiCloseLine,
|
|
RiCommandLine,
|
|
RiCloudLine,
|
|
RiFoldersLine,
|
|
RiGitBranchLine,
|
|
RiNotification3Line,
|
|
RiPaletteLine,
|
|
RiListUnordered,
|
|
RiRobot2Line,
|
|
RiRestartLine,
|
|
RiServerLine,
|
|
RiSlashCommands2,
|
|
} from '@remixicon/react';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
|
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
|
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
|
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
|
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
|
|
import { McpSidebar } from '@/components/sections/mcp/McpSidebar';
|
|
import { McpPage } from '@/components/sections/mcp/McpPage';
|
|
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
|
|
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
|
|
import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar';
|
|
import { ProjectsPage } from '@/components/sections/projects/ProjectsPage';
|
|
import { RemoteInstancesSidebar } from '@/components/sections/remote-instances/RemoteInstancesSidebar';
|
|
import { RemoteInstancesPage } from '@/components/sections/remote-instances/RemoteInstancesPage';
|
|
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
|
|
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
|
import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
|
|
import { UsagePage } from '@/components/sections/usage/UsagePage';
|
|
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
|
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
|
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
|
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
|
import { McpIcon } from '@/components/icons/McpIcon';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
|
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
|
import {
|
|
SETTINGS_PAGE_METADATA,
|
|
getSettingsPageMeta,
|
|
resolveSettingsSlug,
|
|
type SettingsPageSlug,
|
|
type SettingsRuntimeContext,
|
|
type SettingsPageMeta,
|
|
} from '@/lib/settings/metadata';
|
|
|
|
// Same constraints as main sidebar
|
|
const SETTINGS_NAV_MIN_WIDTH = 176;
|
|
const SETTINGS_NAV_MAX_WIDTH = 280;
|
|
const SETTINGS_NAV_RAIL_WIDTH = 48;
|
|
|
|
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
|
|
|
|
interface SettingsViewProps {
|
|
onClose?: () => void;
|
|
/** Force mobile layout regardless of device detection */
|
|
forceMobile?: boolean;
|
|
/** Rendered inside a window/dialog (skip traffic light padding) */
|
|
isWindowed?: boolean;
|
|
}
|
|
|
|
const pageOrder: SettingsPageSlug[] = [
|
|
'appearance',
|
|
'chat',
|
|
'notifications',
|
|
'sessions',
|
|
'shortcuts',
|
|
'git',
|
|
'projects',
|
|
'remote-instances',
|
|
'agents',
|
|
'commands',
|
|
'mcp',
|
|
'providers',
|
|
'usage',
|
|
'skills.installed',
|
|
'skills.catalog',
|
|
];
|
|
|
|
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
|
|
const isVSCode = isVSCodeRuntime();
|
|
const isWeb = !isDesktop && isWebRuntime();
|
|
return { isVSCode, isWeb, isDesktop };
|
|
}
|
|
|
|
function isPageAvailable(page: SettingsPageMeta, ctx: SettingsRuntimeContext): boolean {
|
|
if (!page.isAvailable) {
|
|
return true;
|
|
}
|
|
return page.isAvailable(ctx);
|
|
}
|
|
|
|
function getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<{ className?: string }> | null {
|
|
switch (slug) {
|
|
case 'projects':
|
|
return RiFoldersLine;
|
|
case 'remote-instances':
|
|
return RiServerLine;
|
|
case 'appearance':
|
|
return RiPaletteLine;
|
|
case 'chat':
|
|
return RiChatAi3Line;
|
|
case 'notifications':
|
|
return RiNotification3Line;
|
|
case 'shortcuts':
|
|
return RiCommandLine;
|
|
case 'sessions':
|
|
return RiChatHistoryLine;
|
|
|
|
case 'providers':
|
|
return RiCloudLine;
|
|
case 'agents':
|
|
return RiAiAgentLine;
|
|
case 'commands':
|
|
return RiSlashCommands2;
|
|
case 'mcp':
|
|
return McpIcon;
|
|
|
|
case 'skills.installed':
|
|
return RiBookOpenLine;
|
|
case 'skills.catalog':
|
|
return RiBookLine;
|
|
|
|
case 'git':
|
|
return RiGitBranchLine;
|
|
|
|
case 'usage':
|
|
return RiBarChart2Line;
|
|
case 'home':
|
|
return null;
|
|
default:
|
|
return RiRobot2Line;
|
|
}
|
|
}
|
|
|
|
const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ onOpen }) => {
|
|
return (
|
|
<div className="h-full overflow-auto">
|
|
<div className="mx-auto w-full max-w-3xl px-6 py-6 space-y-6">
|
|
<div className="space-y-1">
|
|
<h1 className="typography-ui-header font-semibold text-foreground">Settings</h1>
|
|
<p className="typography-ui text-muted-foreground">Jump to common pages.</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen('providers')}
|
|
className={cn(
|
|
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
|
'hover:bg-[var(--interactive-hover)] transition-colors'
|
|
)}
|
|
>
|
|
<div className="typography-ui-label text-foreground">Providers</div>
|
|
<div className="typography-micro text-muted-foreground/70">Connect models + credentials</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen('agents')}
|
|
className={cn(
|
|
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
|
'hover:bg-[var(--interactive-hover)] transition-colors'
|
|
)}
|
|
>
|
|
<div className="typography-ui-label text-foreground">Agents</div>
|
|
<div className="typography-micro text-muted-foreground/70">Prompts, tools, permissions</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen('skills.catalog')}
|
|
className={cn(
|
|
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
|
'hover:bg-[var(--interactive-hover)] transition-colors'
|
|
)}
|
|
>
|
|
<div className="typography-ui-label text-foreground">Skills Catalog</div>
|
|
<div className="typography-micro text-muted-foreground/70">Install skills from catalogs</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen('mcp')}
|
|
className={cn(
|
|
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
|
'hover:bg-[var(--interactive-hover)] transition-colors'
|
|
)}
|
|
>
|
|
<div className="typography-ui-label text-foreground">MCP</div>
|
|
<div className="typography-micro text-muted-foreground/70">Configure MCP servers + connections</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen('usage')}
|
|
className={cn(
|
|
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
|
'hover:bg-[var(--interactive-hover)] transition-colors'
|
|
)}
|
|
>
|
|
<div className="typography-ui-label text-foreground">Usage</div>
|
|
<div className="typography-micro text-muted-foreground/70">Quota + spend visibility</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed }) => {
|
|
const deviceInfo = useDeviceInfo();
|
|
const isMobile = forceMobile ?? deviceInfo.isMobile;
|
|
|
|
const settingsPageRaw = useUIStore((state) => state.settingsPage);
|
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
|
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
|
|
|
const [mobileStage, setMobileStage] = React.useState<MobileStage>('nav');
|
|
const autoNavSlugRef = React.useRef<string | null>(null);
|
|
|
|
const [navWidth, setNavWidth] = React.useState(216);
|
|
const [hasManuallyResized, setHasManuallyResized] = React.useState(false);
|
|
const [isResizing, setIsResizing] = React.useState(false);
|
|
const startXRef = React.useRef(0);
|
|
const startWidthRef = React.useRef(navWidth);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
const isDesktopApp = React.useMemo(() => {
|
|
return isDesktopShell();
|
|
}, []);
|
|
|
|
// keep platform check available for future window chrome tweaks
|
|
|
|
const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp), [isDesktopApp]);
|
|
|
|
const visiblePages = React.useMemo(() => {
|
|
return SETTINGS_PAGE_METADATA
|
|
.filter((page) => page.slug !== 'home')
|
|
.filter((page) => isPageAvailable(page, runtimeCtx))
|
|
.filter((page) => !(runtimeCtx.isVSCode && page.slug === 'projects'))
|
|
.filter((page) => !(isMobile && page.slug === 'shortcuts'));
|
|
}, [runtimeCtx, isMobile]);
|
|
|
|
const sortedFilteredPages = React.useMemo(() => {
|
|
const rank = new Map<SettingsPageSlug, number>(pageOrder.map((s, i) => [s, i]));
|
|
return visiblePages
|
|
.slice()
|
|
.sort((a, b) => (rank.get(a.slug) ?? 999) - (rank.get(b.slug) ?? 999));
|
|
}, [visiblePages]);
|
|
|
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const handleResize = () => {
|
|
if (!hasManuallyResized) {
|
|
const proportionalWidth = Math.min(
|
|
SETTINGS_NAV_MAX_WIDTH,
|
|
Math.max(SETTINGS_NAV_MIN_WIDTH, Math.floor(window.innerWidth * 0.12))
|
|
);
|
|
setNavWidth(proportionalWidth);
|
|
}
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, [hasManuallyResized]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isResizing) return;
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
const delta = event.clientX - startXRef.current;
|
|
const nextWidth = Math.min(
|
|
SETTINGS_NAV_MAX_WIDTH,
|
|
Math.max(SETTINGS_NAV_MIN_WIDTH, startWidthRef.current + delta)
|
|
);
|
|
setNavWidth(nextWidth);
|
|
setHasManuallyResized(true);
|
|
};
|
|
const handlePointerUp = () => setIsResizing(false);
|
|
window.addEventListener('pointermove', handlePointerMove);
|
|
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
|
return () => {
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
};
|
|
}, [isResizing]);
|
|
|
|
const handlePointerDown = (event: React.PointerEvent) => {
|
|
setIsResizing(true);
|
|
startXRef.current = event.clientX;
|
|
startWidthRef.current = navWidth;
|
|
event.preventDefault();
|
|
};
|
|
|
|
// Load stores when project changes or when a page becomes active.
|
|
React.useEffect(() => {
|
|
if (settingsSlug === 'agents') {
|
|
setTimeout(() => void useAgentsStore.getState().loadAgents(), 0);
|
|
return;
|
|
}
|
|
if (settingsSlug === 'commands') {
|
|
setTimeout(() => void useCommandsStore.getState().loadCommands(), 0);
|
|
return;
|
|
}
|
|
if (settingsSlug === 'mcp') {
|
|
setTimeout(() => void useMcpConfigStore.getState().loadMcpConfigs(), 0);
|
|
return;
|
|
}
|
|
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
|
|
setTimeout(() => {
|
|
void useSkillsStore.getState().loadSkills();
|
|
void useSkillsCatalogStore.getState().loadCatalog();
|
|
}, 0);
|
|
}
|
|
}, [activeProjectId, settingsSlug]);
|
|
|
|
const openPage = React.useCallback((slug: SettingsPageSlug) => {
|
|
setSettingsPage(slug);
|
|
autoNavSlugRef.current = slug;
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
const def = getSettingsPageMeta(slug);
|
|
if (!def || def.slug === 'home') {
|
|
setMobileStage('nav');
|
|
return;
|
|
}
|
|
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
|
|
}, [isMobile, setSettingsPage]);
|
|
|
|
const activePageMeta = React.useMemo(() => {
|
|
return getSettingsPageMeta(settingsSlug);
|
|
}, [settingsSlug]);
|
|
|
|
// Collapse main nav to icon rail when active page has its own sidebar
|
|
const isNavCollapsed = !isMobile && activePageMeta?.kind === 'split';
|
|
|
|
const openChamberSectionBySlug: Partial<Record<SettingsPageSlug, OpenChamberSection>> = React.useMemo(() => ({
|
|
appearance: 'visual',
|
|
chat: 'chat',
|
|
shortcuts: 'shortcuts',
|
|
sessions: 'sessions',
|
|
notifications: 'notifications',
|
|
}), []);
|
|
|
|
const renderUnavailable = React.useCallback(() => {
|
|
return (
|
|
<div className="flex h-full items-center justify-center px-6">
|
|
<div className="max-w-md text-center">
|
|
<div className="typography-ui-header font-semibold text-foreground">Not available</div>
|
|
<p className="typography-ui text-muted-foreground mt-1">This settings page is not available in this runtime.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, []);
|
|
|
|
const renderPageSidebar = React.useCallback((slug: SettingsPageSlug, opts: { onItemSelect?: () => void }) => {
|
|
switch (slug) {
|
|
case 'projects':
|
|
return <ProjectsSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'remote-instances':
|
|
return <RemoteInstancesSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'agents':
|
|
return <AgentsSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'commands':
|
|
return <CommandsSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'mcp':
|
|
return <McpSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'skills.installed':
|
|
return <SkillsSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'providers':
|
|
return <ProvidersSidebar onItemSelect={opts.onItemSelect} />;
|
|
case 'usage':
|
|
return <UsageSidebar onItemSelect={opts.onItemSelect} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const renderPageContent = React.useCallback((slug: SettingsPageSlug) => {
|
|
const meta = getSettingsPageMeta(slug);
|
|
if (meta && !isPageAvailable(meta, runtimeCtx)) {
|
|
return renderUnavailable();
|
|
}
|
|
|
|
switch (slug) {
|
|
case 'home':
|
|
return <SettingsHome onOpen={openPage} />;
|
|
case 'projects':
|
|
return <ProjectsPage />;
|
|
case 'remote-instances':
|
|
return <RemoteInstancesPage />;
|
|
case 'agents':
|
|
return <AgentsPage />;
|
|
case 'commands':
|
|
return <CommandsPage />;
|
|
case 'mcp':
|
|
return <McpPage />;
|
|
case 'skills.installed':
|
|
return <SkillsPage view="installed" />;
|
|
case 'skills.catalog':
|
|
return <SkillsPage view="catalog" />;
|
|
case 'providers':
|
|
return <ProvidersPage />;
|
|
case 'usage':
|
|
return <UsagePage />;
|
|
case 'git':
|
|
return <GitPage />;
|
|
case 'appearance':
|
|
case 'chat':
|
|
case 'shortcuts':
|
|
case 'sessions':
|
|
case 'notifications': {
|
|
const section = openChamberSectionBySlug[slug] ?? 'visual';
|
|
return <OpenChamberPage section={section} />;
|
|
}
|
|
default:
|
|
return <SettingsHome onOpen={openPage} />;
|
|
}
|
|
}, [openChamberSectionBySlug, openPage, renderUnavailable, runtimeCtx]);
|
|
|
|
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
|
|
React.useEffect(() => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
if (mobileStage !== 'nav') {
|
|
return;
|
|
}
|
|
if (settingsSlug === 'home') {
|
|
return;
|
|
}
|
|
if (autoNavSlugRef.current === settingsSlug) {
|
|
return;
|
|
}
|
|
const def = getSettingsPageMeta(settingsSlug);
|
|
if (!def || def.slug === 'home') {
|
|
return;
|
|
}
|
|
autoNavSlugRef.current = settingsSlug;
|
|
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
|
|
}, [isMobile, mobileStage, settingsSlug]);
|
|
|
|
const showBackButton = isMobile && mobileStage !== 'nav';
|
|
const shortcutKey = getModifierLabel();
|
|
|
|
const handleBack = React.useCallback(() => {
|
|
setMobileStage('nav');
|
|
}, []);
|
|
|
|
const handleOpenPageSidebar = React.useCallback(() => {
|
|
setMobileStage('page-sidebar');
|
|
}, []);
|
|
|
|
const renderSettingsNav = (collapsed: boolean) => {
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
{/* Scrollable nav items */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
|
<div className="flex flex-col gap-0.5 pt-4 pb-2 px-2">
|
|
{sortedFilteredPages.map((page) => {
|
|
const selected = settingsSlug === page.slug;
|
|
const Icon = getSettingsNavIcon(page.slug);
|
|
if (!Icon) return null;
|
|
|
|
return (
|
|
<Tooltip key={page.slug} delayDuration={collapsed ? 100 : 600}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPage(page.slug)}
|
|
className={cn(
|
|
'flex h-8 items-center gap-2 rounded-md px-2 overflow-hidden',
|
|
selected
|
|
? 'bg-interactive-selection text-foreground'
|
|
: 'text-foreground hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
<span
|
|
className={cn(
|
|
'flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150',
|
|
collapsed ? 'opacity-0' : 'opacity-100'
|
|
)}
|
|
>
|
|
<span className="typography-ui-label font-normal truncate">{page.title}</span>
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
{collapsed && (
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
{page.title}
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer — hidden when collapsed via overflow on parent */}
|
|
<div
|
|
className={cn(
|
|
'overflow-hidden transition-opacity duration-150',
|
|
collapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
|
)}
|
|
>
|
|
<div className="border-t border-border bg-sidebar px-2 py-1 space-y-0.5">
|
|
{!runtimeCtx.isVSCode && (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'flex h-7 w-full items-center gap-2 rounded-md px-2 overflow-hidden whitespace-nowrap',
|
|
'text-sm font-semibold text-sidebar-foreground/90',
|
|
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
|
)}
|
|
onClick={() => void reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] })}
|
|
>
|
|
<RiRestartLine className="h-4 w-4 shrink-0" />
|
|
<span>Reload OpenCode</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
Restart OpenCode and reload its configuration.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{isMobile && runtimeCtx.isWeb && (
|
|
<div className="px-1.5 pt-2">
|
|
<AboutSettings />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderMobileStage = () => {
|
|
if (mobileStage === 'nav') {
|
|
return (
|
|
<div className={cn('flex-1 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<ErrorBoundary>{renderSettingsNav(false)}</ErrorBoundary>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!activePageMeta) {
|
|
return <div className="flex-1 bg-background" />;
|
|
}
|
|
|
|
if (mobileStage === 'page-sidebar') {
|
|
if (activePageMeta.kind !== 'split') {
|
|
// No sidebar available; fall back to direct content.
|
|
const fallback = renderPageContent(settingsSlug);
|
|
return (
|
|
<div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
|
|
<ErrorBoundary>{fallback}</ErrorBoundary>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={cn('flex-1 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
|
<ErrorBoundary>
|
|
{renderPageSidebar(settingsSlug, { onItemSelect: () => setMobileStage('page-content') })}
|
|
</ErrorBoundary>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// page-content
|
|
const content = renderPageContent(settingsSlug);
|
|
|
|
return (
|
|
<div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
|
|
<ErrorBoundary>{content}</ErrorBoundary>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderDesktopContent = () => {
|
|
if (!activePageMeta || settingsSlug === 'home') {
|
|
return <SettingsHome onOpen={openPage} />;
|
|
}
|
|
|
|
if (activePageMeta.kind === 'split') {
|
|
return (
|
|
<div className="flex h-full min-h-0 overflow-hidden">
|
|
<div className={cn('w-[264px] min-w-[264px] border-r', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')} style={{ borderColor: 'var(--interactive-border)' }}>
|
|
<ErrorBoundary>{renderPageSidebar(settingsSlug, {})}</ErrorBoundary>
|
|
</div>
|
|
<div className="flex-1 overflow-hidden bg-background">
|
|
<ErrorBoundary>{renderPageContent(settingsSlug)}</ErrorBoundary>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-hidden bg-background">
|
|
<ErrorBoundary>{renderPageContent(settingsSlug)}</ErrorBoundary>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} data-settings-view="true" className={cn('relative flex h-full flex-col overflow-hidden bg-background')}>
|
|
{isMobile ? (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-2 px-3 py-2 border-b',
|
|
'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
|
)}
|
|
style={{ borderColor: 'var(--interactive-border)' }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={showBackButton ? handleBack : onClose}
|
|
aria-label={showBackButton ? 'Back to Settings' : 'Close settings'}
|
|
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
<RiArrowLeftSLine className="h-5 w-5" />
|
|
</button>
|
|
|
|
<div className="min-w-0 flex-1 typography-ui-label font-medium text-foreground truncate">
|
|
{mobileStage === 'nav'
|
|
? 'Settings'
|
|
: (activePageMeta?.title ?? 'Settings')}
|
|
</div>
|
|
|
|
{mobileStage === 'page-content' && activePageMeta?.kind === 'split' && (
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenPageSidebar}
|
|
aria-label="Open section list"
|
|
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
<RiListUnordered className="h-5 w-5" />
|
|
</button>
|
|
)}
|
|
|
|
{onClose && (
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close settings"
|
|
title={`Close Settings (${shortcutKey}+,)`}
|
|
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
<RiCloseLine className="h-5 w-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{showBackButton && (
|
|
<div className={cn('absolute left-3 z-50', isWindowed ? 'top-2' : 'top-3')}>
|
|
<button
|
|
type="button"
|
|
onClick={handleBack}
|
|
aria-label="Back"
|
|
className="inline-flex h-9 w-9 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
<RiArrowLeftSLine className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{onClose && (
|
|
<div className={cn('absolute right-0.5 z-50', isWindowed ? 'top-0.5' : 'top-1')}>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close settings"
|
|
title={`Close Settings (${shortcutKey}+,)`}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
<RiCloseLine className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{isMobile ? (
|
|
renderMobileStage()
|
|
) : (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'relative flex h-full min-h-0 flex-col overflow-hidden border-r',
|
|
isDesktopApp
|
|
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
|
: runtimeCtx.isVSCode
|
|
? 'bg-background'
|
|
: 'bg-sidebar',
|
|
isResizing && !isNavCollapsed ? '' : 'transition-[width,min-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]'
|
|
)}
|
|
style={{
|
|
width: isNavCollapsed ? `${SETTINGS_NAV_RAIL_WIDTH}px` : `${navWidth}px`,
|
|
minWidth: isNavCollapsed ? `${SETTINGS_NAV_RAIL_WIDTH}px` : `${navWidth}px`,
|
|
borderColor: 'var(--interactive-border)',
|
|
}}
|
|
>
|
|
{!isNavCollapsed && (
|
|
<div
|
|
className={cn(
|
|
'absolute right-0 top-0 z-20 h-full w-[6px] -mr-[3px] cursor-col-resize',
|
|
isResizing ? 'bg-primary/30' : 'bg-transparent hover:bg-primary/20'
|
|
)}
|
|
onPointerDown={handlePointerDown}
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="Resize settings navigation"
|
|
/>
|
|
)}
|
|
<ErrorBoundary>
|
|
{renderSettingsNav(isNavCollapsed)}
|
|
</ErrorBoundary>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden bg-background">
|
|
{renderDesktopContent()}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|