chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
4a37b9a005
commit
00821700de
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/constants/sidebar';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
interface SectionPlaceholderProps {
|
||||
sectionId: SidebarSection;
|
||||
variant: 'sidebar' | 'page';
|
||||
}
|
||||
|
||||
export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionId, variant }) => {
|
||||
const { t } = useI18n();
|
||||
const config = SIDEBAR_SECTION_CONFIG_MAP[sectionId];
|
||||
const icon = config.icon;
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-3 text-muted-foreground">
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-5 w-5" /> : <Icon name={icon} className="h-5 w-5" />}
|
||||
</div>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{config.label}</h3>
|
||||
<p className="typography-meta max-w-xs text-muted-foreground">
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-4 text-muted-foreground">
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-8 w-8" /> : <Icon name={icon} className="h-8 w-8" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">{config.label}</h2>
|
||||
<p className="typography-body max-w-md text-muted-foreground">
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/60">{t('settings.common.state.comingSoon')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -384,5 +384,5 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
};
|
||||
|
||||
// Re-export for easy sidebar icon usage
|
||||
export { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface ImportedMcpResult {
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export type ImportedMcpError =
|
||||
type ImportedMcpError =
|
||||
| { readonly ok: false; readonly error: string }
|
||||
| { readonly ok: false; readonly error: string; readonly parsed: unknown };
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const parseMcpOAuthCallbackStateKey = (params: URLSearchParams): string |
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
export const parseMcpOAuthState = (raw: string | null | undefined): {
|
||||
const parseMcpOAuthState = (raw: string | null | undefined): {
|
||||
name: string;
|
||||
directory: string | null;
|
||||
} | null => {
|
||||
|
||||
@@ -234,7 +234,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
|
||||
@@ -300,5 +300,3 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPluginDialog;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { PluginsSidebar } from './PluginsSidebar';
|
||||
export { PluginsPage } from './PluginsPage';
|
||||
export { AddPluginDialog } from './AddPluginDialog';
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
|
||||
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { DesktopSshInstance } from '@/lib/desktopSsh';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type RemoteInstancesSidebarProps = {
|
||||
onItemSelect?: () => void;
|
||||
};
|
||||
|
||||
const makeId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const DIRECT_INSTANCES_ID = '__direct_instances__';
|
||||
|
||||
const randomPort = (): number => {
|
||||
return Math.floor(20000 + Math.random() * 30000);
|
||||
};
|
||||
|
||||
const isPortInUseError = (error: unknown): boolean => {
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
|
||||
};
|
||||
|
||||
const phaseLabelKey = (phase?: string) => {
|
||||
switch (phase) {
|
||||
case 'ready':
|
||||
return 'settings.remoteInstances.sidebar.phase.ready';
|
||||
case 'error':
|
||||
return 'settings.remoteInstances.sidebar.phase.error';
|
||||
case 'degraded':
|
||||
return 'settings.remoteInstances.sidebar.phase.reconnect';
|
||||
case 'installing':
|
||||
return 'settings.remoteInstances.sidebar.phase.installing';
|
||||
case 'updating':
|
||||
return 'settings.remoteInstances.sidebar.phase.updating';
|
||||
case 'forwarding':
|
||||
return 'settings.remoteInstances.sidebar.phase.forwarding';
|
||||
case 'server_starting':
|
||||
return 'settings.remoteInstances.sidebar.phase.starting';
|
||||
case 'master_connecting':
|
||||
return 'settings.remoteInstances.sidebar.phase.connecting';
|
||||
default:
|
||||
return 'settings.remoteInstances.sidebar.phase.idle';
|
||||
}
|
||||
};
|
||||
|
||||
export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const isLoading = useDesktopSshStore((state) => state.isLoading);
|
||||
const load = useDesktopSshStore((state) => state.load);
|
||||
const loadImports = useDesktopSshStore((state) => state.loadImports);
|
||||
const createFromCommand = useDesktopSshStore((state) => state.createFromCommand);
|
||||
const connect = useDesktopSshStore((state) => state.connect);
|
||||
const disconnect = useDesktopSshStore((state) => state.disconnect);
|
||||
const retry = useDesktopSshStore((state) => state.retry);
|
||||
const removeInstance = useDesktopSshStore((state) => state.removeInstance);
|
||||
const upsertInstance = useDesktopSshStore((state) => state.upsertInstance);
|
||||
|
||||
const selectedId = useUIStore((state) => state.settingsRemoteInstancesSelectedId);
|
||||
const setSelectedId = useUIStore((state) => state.setSettingsRemoteInstancesSelectedId);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
void loadImports();
|
||||
}, [load, loadImports]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (selectedId === DIRECT_INSTANCES_ID) {
|
||||
return;
|
||||
}
|
||||
if (instances.length === 0) {
|
||||
if (selectedId !== null) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedId && instances.some((instance) => instance.id === selectedId)) {
|
||||
return;
|
||||
}
|
||||
setSelectedId(instances[0].id);
|
||||
}, [instances, isLoading, selectedId, setSelectedId]);
|
||||
|
||||
const handleAdd = React.useCallback(async () => {
|
||||
const id = makeId();
|
||||
try {
|
||||
await createFromCommand(id, 'ssh user@example.com', t('settings.remoteInstances.sidebar.newSshInstanceName'));
|
||||
setSelectedId(id);
|
||||
onItemSelect?.();
|
||||
} catch (error) {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [createFromCommand, onItemSelect, setSelectedId, t]);
|
||||
|
||||
const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => {
|
||||
try {
|
||||
await connect(instance.id);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isPortInUseError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
|
||||
if (!allow) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextInstance: DesktopSshInstance = {
|
||||
...instance,
|
||||
localForward: {
|
||||
...instance.localForward,
|
||||
preferredLocalPort: randomPort(),
|
||||
},
|
||||
};
|
||||
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
}
|
||||
}, [connect, t, upsertInstance]);
|
||||
|
||||
return (
|
||||
<SettingsSidebarLayout
|
||||
variant="background"
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.remoteInstances.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => void handleAdd()}
|
||||
aria-label={t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
>
|
||||
<Icon name="add" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsSidebarItem
|
||||
title={t('settings.remoteInstances.direct.sidebarTitle')}
|
||||
metadata={t('settings.remoteInstances.direct.sidebarDescription')}
|
||||
selected={selectedId === DIRECT_INSTANCES_ID || (!selectedId && instances.length === 0)}
|
||||
onSelect={() => {
|
||||
setSelectedId(DIRECT_INSTANCES_ID);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
icon={<Icon name="global" className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
{instances.map((instance) => {
|
||||
const status = statusesById[instance.id];
|
||||
const selected = instance.id === selectedId;
|
||||
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
|
||||
const metadata = `${t(phaseLabelKey(status?.phase))}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
|
||||
const isReady = status?.phase === 'ready';
|
||||
const canRetry = status?.phase === 'error' || status?.phase === 'degraded';
|
||||
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
key={instance.id}
|
||||
title={title}
|
||||
metadata={metadata}
|
||||
selected={selected}
|
||||
onSelect={() => {
|
||||
setSelectedId(instance.id);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
label: isReady ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect'),
|
||||
icon: isReady ? 'stop' : 'plug-2',
|
||||
onClick: () => {
|
||||
const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance);
|
||||
void op.catch((error) => {
|
||||
toast.error(
|
||||
isReady
|
||||
? t('settings.remoteInstances.sidebar.toast.disconnectFailed')
|
||||
: t('settings.remoteInstances.sidebar.toast.connectFailed'),
|
||||
{
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('settings.remoteInstances.sidebar.actions.retry'),
|
||||
icon: "refresh",
|
||||
onClick: () => {
|
||||
if (!canRetry) return;
|
||||
void retry(instance.id).catch((error) => {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.retryFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('settings.remoteInstances.sidebar.actions.remove'),
|
||||
icon: "delete-bin",
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
void removeInstance(instance.id).then(() => {
|
||||
if (selectedId === instance.id) {
|
||||
const next = instances.find((item) => item.id !== instance.id);
|
||||
setSelectedId(next?.id || null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.removeFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SettingsSidebarLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
/** Section content */
|
||||
children: React.ReactNode;
|
||||
/** Optional section title */
|
||||
title?: string;
|
||||
/** Optional section description */
|
||||
description?: string;
|
||||
/** If true, adds a top border divider */
|
||||
divider?: boolean;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard section wrapper for settings page content.
|
||||
* Provides consistent spacing and optional divider.
|
||||
*
|
||||
* @example
|
||||
* <SettingsSection title="Appearance" description="Customize the look and feel">
|
||||
* <ThemeSelector />
|
||||
* <FontSizeSelector />
|
||||
* </SettingsSection>
|
||||
*
|
||||
* <SettingsSection divider>
|
||||
* <DangerZoneSettings />
|
||||
* </SettingsSection>
|
||||
*/
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
divider = false,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
divider && 'border-t border-border/40 pt-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || description) && (
|
||||
<div className="mb-4 space-y-1">
|
||||
{title && (
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSidebarHeaderProps {
|
||||
/** Total count to display (e.g., "Total 5") */
|
||||
count: number;
|
||||
/** Callback when add button is clicked. If undefined, no add button is shown. */
|
||||
onAdd?: () => void;
|
||||
/** Custom label prefix (default: "Total") */
|
||||
label?: string;
|
||||
/** Aria label for the add button */
|
||||
addButtonLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard header for settings sidebars.
|
||||
* Displays "Total X" on the left and an optional add button on the right.
|
||||
*
|
||||
* @example
|
||||
* <SettingsSidebarHeader
|
||||
* count={agents.length}
|
||||
* onAdd={() => setCreateDialogOpen(true)}
|
||||
* addButtonLabel="Create new agent"
|
||||
* />
|
||||
*/
|
||||
export const SettingsSidebarHeader: React.FC<SettingsSidebarHeaderProps> = ({
|
||||
count,
|
||||
onAdd,
|
||||
label = 'Total',
|
||||
addButtonLabel = 'Add new item',
|
||||
}) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-3',
|
||||
isMobile ? 'mt-2 py-3' : 'py-3'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{label} {count}
|
||||
</span>
|
||||
{onAdd && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={onAdd}
|
||||
aria-label={addButtonLabel}
|
||||
>
|
||||
<Icon name="add" className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SettingsSidebarItemAction {
|
||||
interface SettingsSidebarItemAction {
|
||||
/** Label shown in dropdown menu */
|
||||
label: string;
|
||||
/** Icon component to show before label */
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Shared boilerplate components for settings sections.
|
||||
*
|
||||
* These components provide consistent styling and behavior for settings sidebars and pages.
|
||||
* Use them as building blocks when creating new settings sections.
|
||||
*
|
||||
* @example Sidebar usage:
|
||||
* ```tsx
|
||||
* import {
|
||||
* SettingsSidebarLayout,
|
||||
* SettingsSidebarHeader,
|
||||
* SettingsSidebarItem,
|
||||
* } from '@/components/sections/shared';
|
||||
*
|
||||
* export const MySidebar = () => (
|
||||
* <SettingsSidebarLayout
|
||||
* header={<SettingsSidebarHeader count={items.length} onAdd={handleAdd} />}
|
||||
* >
|
||||
* {items.map(item => (
|
||||
* <SettingsSidebarItem
|
||||
* key={item.id}
|
||||
* title={item.name}
|
||||
* metadata={item.description}
|
||||
* selected={selectedId === item.id}
|
||||
* onSelect={() => setSelectedId(item.id)}
|
||||
* actions={[
|
||||
* { label: 'Delete', onClick: () => handleDelete(item.id), destructive: true }
|
||||
* ]}
|
||||
* />
|
||||
* ))}
|
||||
* </SettingsSidebarLayout>
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Page usage:
|
||||
* ```tsx
|
||||
* import { SettingsPageLayout, SettingsSection } from '@/components/sections/shared';
|
||||
*
|
||||
* export const MyPage = () => (
|
||||
* <SettingsPageLayout>
|
||||
* <SettingsSection title="General Settings">
|
||||
* <MySettingsForm />
|
||||
* </SettingsSection>
|
||||
* <SettingsSection title="Advanced" divider>
|
||||
* <AdvancedSettingsForm />
|
||||
* </SettingsSection>
|
||||
* </SettingsPageLayout>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { SettingsSidebarLayout } from './SettingsSidebarLayout';
|
||||
export { SettingsSidebarHeader } from './SettingsSidebarHeader';
|
||||
export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem';
|
||||
export { SettingsPageLayout } from './SettingsPageLayout';
|
||||
export { SettingsSection } from './SettingsSection';
|
||||
export { SidebarGroup } from './SidebarGroup';
|
||||
@@ -1,597 +0,0 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from '../skillLocations';
|
||||
|
||||
interface InstallFromRepoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const scanRepo = useSkillsCatalogStore((s) => s.scanRepo);
|
||||
const installSkills = useSkillsCatalogStore((s) => s.installSkills);
|
||||
const isScanning = useSkillsCatalogStore((s) => s.isScanning);
|
||||
const isInstalling = useSkillsCatalogStore((s) => s.isInstalling);
|
||||
const installedSkills = useSkillsStore((s) => s.skills);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
|
||||
|
||||
const [source, setSource] = React.useState('');
|
||||
const [subpath, setSubpath] = React.useState('');
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
|
||||
const [items, setItems] = React.useState<SkillsCatalogItem[]>([]);
|
||||
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const [identities, setIdentities] = React.useState<IdentityOption[]>([]);
|
||||
const [gitIdentityId, setGitIdentityId] = React.useState<string | null>(null);
|
||||
const scanRequestIdRef = React.useRef(0);
|
||||
|
||||
const invalidateScan = React.useCallback((options?: { clearIdentities?: boolean }) => {
|
||||
scanRequestIdRef.current += 1;
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
if (options?.clearIdentities) {
|
||||
setIdentities([]);
|
||||
setGitIdentityId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [conflictsOpen, setConflictsOpen] = React.useState(false);
|
||||
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
|
||||
const [baseInstallRequest, setBaseInstallRequest] = React.useState<{
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
selections: Array<{ skillDir: string }>;
|
||||
gitIdentityId?: string;
|
||||
directoryOverride?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
scanRequestIdRef.current += 1;
|
||||
if (!open) return;
|
||||
setSource('');
|
||||
setSubpath('');
|
||||
setScope('user');
|
||||
setTargetSource('opencode');
|
||||
setTargetProjectId(activeProjectId);
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
setSearch('');
|
||||
setIdentities([]);
|
||||
setGitIdentityId(null);
|
||||
void loadDefaultGitIdentityId();
|
||||
|
||||
setConflictsOpen(false);
|
||||
|
||||
setConflicts([]);
|
||||
setBaseInstallRequest(null);
|
||||
}, [open, loadDefaultGitIdentityId, activeProjectId]);
|
||||
|
||||
const resolvedTargetProjectId = React.useMemo(() => {
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
|
||||
return targetProjectId;
|
||||
}
|
||||
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
|
||||
return activeProjectId;
|
||||
}
|
||||
return projects[0]?.id ?? null;
|
||||
}, [activeProjectId, projects, targetProjectId]);
|
||||
|
||||
const directoryOverride = React.useMemo(() => {
|
||||
if (scope !== 'project') {
|
||||
return null;
|
||||
}
|
||||
const id = resolvedTargetProjectId;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const project = projects.find((p) => p.id === id);
|
||||
return project?.path ?? null;
|
||||
}, [projects, resolvedTargetProjectId, scope]);
|
||||
|
||||
const installedByName = React.useMemo(() => {
|
||||
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
|
||||
for (const s of installedSkills) {
|
||||
map.set(s.name, { scope: s.scope, source: s.source });
|
||||
}
|
||||
return map;
|
||||
}, [installedSkills]);
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
|
||||
const selectedDirs = React.useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]);
|
||||
|
||||
const toggleAll = (value: boolean) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const item of items) {
|
||||
if (!item.installable) continue;
|
||||
next[item.skillDir] = value;
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('settings.skills.catalog.shared.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
const requestId = scanRequestIdRef.current + 1;
|
||||
scanRequestIdRef.current = requestId;
|
||||
|
||||
const result = await scanRepo({
|
||||
source: trimmed,
|
||||
subpath: subpath.trim() || undefined,
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
});
|
||||
|
||||
if (scanRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentities(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
const preferred =
|
||||
defaultGitIdentityId &&
|
||||
defaultGitIdentityId !== 'global' &&
|
||||
ids.some((i) => i.id === defaultGitIdentityId)
|
||||
? defaultGitIdentityId
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = result.items || [];
|
||||
setItems(nextItems);
|
||||
|
||||
// Auto-select all installable items when scanning returns a small set.
|
||||
const nextSelected: Record<string, boolean> = {};
|
||||
for (const item of nextItems) {
|
||||
if (item.installable) {
|
||||
nextSelected[item.skillDir] = true;
|
||||
}
|
||||
}
|
||||
setSelected(nextSelected);
|
||||
|
||||
setIdentities([]);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count: nextItems.length }));
|
||||
};
|
||||
|
||||
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.selectAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
source: source.trim(),
|
||||
subpath: subpath.trim() || undefined,
|
||||
scope,
|
||||
targetSource,
|
||||
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
directoryOverride,
|
||||
};
|
||||
|
||||
const result = await installSkills(
|
||||
{
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: request.selections,
|
||||
gitIdentityId: request.gitIdentityId,
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: opts.conflictDecisions,
|
||||
},
|
||||
{ directory: request.directoryOverride ?? null }
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
const installedCount = result.installed?.length || 0;
|
||||
toast.success(
|
||||
installedCount > 0
|
||||
? t('settings.skills.catalog.installFromRepo.toast.installedCount', { count: installedCount })
|
||||
: t('settings.skills.catalog.installFromRepo.toast.installCompleted')
|
||||
);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
setBaseInstallRequest(request);
|
||||
setConflicts(result.error.conflicts);
|
||||
setConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentities(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
const preferred =
|
||||
defaultGitIdentityId &&
|
||||
defaultGitIdentityId !== 'global' &&
|
||||
ids.some((i) => i.id === defaultGitIdentityId)
|
||||
? defaultGitIdentityId
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.installFailed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{t('settings.skills.catalog.installFromRepo.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.repository')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
invalidateScan({ clearIdentities: true });
|
||||
}}
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleScan()}
|
||||
disabled={isScanning || !source.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon name="git-repository" className="h-4 w-4" />
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.skills.catalog.installFromRepo.repositoryHintPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">owner/repo/skills</code>
|
||||
{'.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => {
|
||||
setSubpath(e.target.value);
|
||||
invalidateScan({ clearIdentities: true });
|
||||
}}
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.targetLocation')}</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setScope(next.scope);
|
||||
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full gap-1.5">
|
||||
{scope === 'user' ? <Icon name="user-3" className="h-4 w-4" /> : <Icon name="folder" className="h-4 w-4" />}
|
||||
{targetSource === 'agents' ? <Icon name="robot-2" className="h-4 w-4" /> : null}
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <Icon name="user-3" className="h-4 w-4" /> : <Icon name="folder" className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <Icon name="robot-2" className="h-4 w-4" /> : null}
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.project')}</label>
|
||||
{projects.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.skills.catalog.shared.field.noProjects')}</p>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
onValueChange={(v) => setTargetProjectId(v)}
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<SelectValue placeholder={t('settings.skills.catalog.shared.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{p.label || p.path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{identities.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.auth.title')}</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
{t('settings.skills.catalog.installFromRepo.authDescription')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={gitIdentityId || ''}
|
||||
onValueChange={(v) => {
|
||||
setGitIdentityId(v);
|
||||
invalidateScan();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identities.map((id) => (
|
||||
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{id.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
{t('settings.skills.catalog.shared.auth.footerHintArrow')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="typography-body">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>{t('settings.skills.catalog.installFromRepo.actions.selectAll')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>{t('settings.skills.catalog.installFromRepo.actions.selectNone')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-2">
|
||||
{filteredItems.map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
const checked = Boolean(selected[item.skillDir]);
|
||||
const disabled = !item.installable;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={item.skillDir}
|
||||
className={
|
||||
'flex items-start gap-3 rounded-lg border bg-muted/10 px-3 py-2 cursor-pointer transition-colors ' +
|
||||
(disabled ? 'opacity-60 cursor-not-allowed' : 'hover:bg-interactive-hover/20')
|
||||
}
|
||||
>
|
||||
<div className="mt-1">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(newChecked) => setSelected((prev) => ({ ...prev, [item.skillDir]: newChecked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.skills.catalog.installFromRepo.badge.installed', {
|
||||
scope: installed.scope,
|
||||
source: installed.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">
|
||||
{item.warnings.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{t('settings.skills.catalog.installFromRepo.selectedCount', {
|
||||
selected: selectedDirs.length,
|
||||
total: items.filter((i) => i.installable).length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? t('settings.skills.catalog.shared.actions.installing') : t('settings.skills.catalog.installFromRepo.actions.installSelected')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InstallConflictsDialog
|
||||
open={conflictsOpen}
|
||||
onOpenChange={setConflictsOpen}
|
||||
conflicts={conflicts}
|
||||
onConfirm={(decisions) => {
|
||||
if (!baseInstallRequest) {
|
||||
setConflictsOpen(false);
|
||||
return;
|
||||
}
|
||||
void doInstall({ conflictDecisions: decisions });
|
||||
setConflictsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { SkillsSidebar } from './SkillsSidebar';
|
||||
export { SkillsPage } from './SkillsPage';
|
||||
@@ -57,10 +57,3 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop
|
||||
}
|
||||
return { scope: match.scope, source: match.source };
|
||||
}
|
||||
|
||||
export function locationLabel(scope: SkillScope, source: SkillSource): string {
|
||||
if (scope === 'user' && source === 'claude') return 'User / Claude';
|
||||
if (scope === 'project' && source === 'claude') return 'Project / Claude';
|
||||
const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source);
|
||||
return match?.label || `${scope} / ${source}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user