@leanspec/ui 0.2.14 → 0.2.15-dev.21025278490
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/leanspec-ui.js +191 -0
- package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
- package/dist/assets/arc-DMhx9AJT.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
- package/dist/assets/channel-2tOl0nAZ.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
- package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
- package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
- package/dist/assets/clone-BjxVFtyI.js +1 -0
- package/dist/assets/core-DV6XEvTN.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
- package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
- package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
- package/dist/assets/graph-B2rEI7cK.js +1 -0
- package/dist/assets/index-Bekv_o1t.css +1 -0
- package/dist/assets/index-DSRxU-E5.js +389 -0
- package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
- package/dist/assets/katex-XbL3y5x-.js +261 -0
- package/dist/assets/layout-iCSHU015.js +1 -0
- package/dist/assets/min-BK_AIJdo.js +1 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
- package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
- package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +12 -2
- package/eslint.config.js +0 -23
- package/package.json.backup +0 -83
- package/postcss.config.js +0 -6
- package/src/App.css +0 -42
- package/src/App.tsx +0 -17
- package/src/assets/react.svg +0 -1
- package/src/components/LanguageSwitcher.tsx +0 -67
- package/src/components/Layout.tsx +0 -88
- package/src/components/MainSidebar.tsx +0 -163
- package/src/components/MermaidDiagram.tsx +0 -85
- package/src/components/MinimalLayout.tsx +0 -51
- package/src/components/Navigation.tsx +0 -254
- package/src/components/PriorityBadge.tsx +0 -59
- package/src/components/ProjectSwitcher.tsx +0 -222
- package/src/components/QuickSearch.tsx +0 -225
- package/src/components/RootRedirect.tsx +0 -40
- package/src/components/SpecDetailLayout.context.ts +0 -10
- package/src/components/SpecDetailLayout.tsx +0 -14
- package/src/components/SpecsNavSidebar.tsx +0 -615
- package/src/components/StatusBadge.tsx +0 -59
- package/src/components/ThemeToggle.tsx +0 -25
- package/src/components/Tooltip.tsx +0 -29
- package/src/components/context/ContextClient.tsx +0 -471
- package/src/components/context/ContextFileDetail.tsx +0 -163
- package/src/components/dashboard/ActivityItem.tsx +0 -36
- package/src/components/dashboard/DashboardClient.tsx +0 -218
- package/src/components/dashboard/SpecListItem.tsx +0 -58
- package/src/components/dashboard/StatCard.tsx +0 -52
- package/src/components/dependencies/SpecNode.tsx +0 -128
- package/src/components/dependencies/SpecSidebar.tsx +0 -256
- package/src/components/dependencies/constants.ts +0 -25
- package/src/components/dependencies/types.ts +0 -38
- package/src/components/dependencies/utils.ts +0 -261
- package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
- package/src/components/metadata-editors/StatusEditor.tsx +0 -85
- package/src/components/metadata-editors/TagsEditor.tsx +0 -207
- package/src/components/projects/CreateProjectDialog.tsx +0 -162
- package/src/components/projects/DirectoryPicker.tsx +0 -182
- package/src/components/shared/BackToTop.tsx +0 -39
- package/src/components/shared/ColorPicker.tsx +0 -68
- package/src/components/shared/EmptyState.tsx +0 -35
- package/src/components/shared/ErrorBoundary.tsx +0 -79
- package/src/components/shared/PageHeader.tsx +0 -23
- package/src/components/shared/PageTransition.tsx +0 -40
- package/src/components/shared/ProjectAvatar.tsx +0 -107
- package/src/components/shared/Skeletons.tsx +0 -184
- package/src/components/spec-detail/EditableMetadata.tsx +0 -129
- package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
- package/src/components/spec-detail/TableOfContents.tsx +0 -150
- package/src/components/specs/BoardView.tsx +0 -204
- package/src/components/specs/ListView.tsx +0 -62
- package/src/components/specs/SpecsFilters.tsx +0 -190
- package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
- package/src/contexts/LayoutContext.tsx +0 -45
- package/src/contexts/ProjectContext.tsx +0 -163
- package/src/contexts/ThemeContext.tsx +0 -90
- package/src/contexts/index.ts +0 -7
- package/src/hooks/useKeyboardShortcuts.ts +0 -87
- package/src/index.css +0 -624
- package/src/lib/api.ts +0 -72
- package/src/lib/backend-adapter.ts +0 -382
- package/src/lib/date-utils.ts +0 -122
- package/src/lib/i18n.test.ts +0 -57
- package/src/lib/i18n.ts +0 -51
- package/src/lib/markdown-utils.ts +0 -38
- package/src/lib/sub-spec-utils.ts +0 -166
- package/src/lib/utils.ts +0 -6
- package/src/locales/en/common.json +0 -660
- package/src/locales/en/errors.json +0 -20
- package/src/locales/en/help.json +0 -8
- package/src/locales/zh-CN/common.json +0 -660
- package/src/locales/zh-CN/errors.json +0 -20
- package/src/locales/zh-CN/help.json +0 -8
- package/src/main.tsx +0 -12
- package/src/pages/ContextPage.tsx +0 -111
- package/src/pages/DashboardPage.tsx +0 -97
- package/src/pages/DependenciesPage.tsx +0 -881
- package/src/pages/ProjectsPage.tsx +0 -432
- package/src/pages/SpecDetailPage.tsx +0 -592
- package/src/pages/SpecsPage.tsx +0 -319
- package/src/pages/StatsPage.tsx +0 -307
- package/src/router/projectRoutes.tsx +0 -36
- package/src/router.tsx +0 -33
- package/src/test/setup.ts +0 -39
- package/src/types/api.ts +0 -185
- package/tailwind.config.ts +0 -57
- package/tsconfig.app.json +0 -29
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/tsconfig.tsbuildinfo +0 -1
- package/vite.config.ts +0 -27
- package/vitest.config.ts +0 -18
- /package/{public → dist}/favicon.ico +0 -0
- /package/{public → dist}/github-mark-white.svg +0 -0
- /package/{public → dist}/github-mark.svg +0 -0
- /package/{public → dist}/logo-dark-bg.svg +0 -0
- /package/{public → dist}/logo-with-bg.svg +0 -0
- /package/{public → dist}/logo.svg +0 -0
- /package/{public → dist}/vite.svg +0 -0
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { Sun, Moon } from 'lucide-react';
|
|
2
|
-
import { useTheme } from '../contexts';
|
|
3
|
-
import { Button } from '@leanspec/ui-components';
|
|
4
|
-
import { useTranslation } from 'react-i18next';
|
|
5
|
-
|
|
6
|
-
export function ThemeToggle() {
|
|
7
|
-
const { setTheme, resolvedTheme } = useTheme();
|
|
8
|
-
const isDark = resolvedTheme === 'dark';
|
|
9
|
-
const { t } = useTranslation('common');
|
|
10
|
-
|
|
11
|
-
const toggleTheme = () => setTheme(isDark ? 'light' : 'dark');
|
|
12
|
-
|
|
13
|
-
return (
|
|
14
|
-
<Button
|
|
15
|
-
variant="ghost"
|
|
16
|
-
size="icon"
|
|
17
|
-
onClick={toggleTheme}
|
|
18
|
-
aria-label={t('theme.toggleTheme')}
|
|
19
|
-
className="relative h-9 w-9"
|
|
20
|
-
>
|
|
21
|
-
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
22
|
-
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
23
|
-
</Button>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
|
3
|
-
import { cn } from '../lib/utils';
|
|
4
|
-
|
|
5
|
-
const TooltipProvider = TooltipPrimitive.Provider;
|
|
6
|
-
|
|
7
|
-
const Tooltip = TooltipPrimitive.Root;
|
|
8
|
-
|
|
9
|
-
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
10
|
-
|
|
11
|
-
const TooltipContent = React.forwardRef<
|
|
12
|
-
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
13
|
-
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
14
|
-
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
15
|
-
<TooltipPrimitive.Portal>
|
|
16
|
-
<TooltipPrimitive.Content
|
|
17
|
-
ref={ref}
|
|
18
|
-
sideOffset={sideOffset}
|
|
19
|
-
className={cn(
|
|
20
|
-
'z-[100] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
21
|
-
className
|
|
22
|
-
)}
|
|
23
|
-
{...props}
|
|
24
|
-
/>
|
|
25
|
-
</TooltipPrimitive.Portal>
|
|
26
|
-
));
|
|
27
|
-
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
28
|
-
|
|
29
|
-
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
|
@@ -1,471 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project Context Client Component
|
|
3
|
-
* Displays project-level context files (AGENTS.md, config, README, etc.)
|
|
4
|
-
* Aligned with Next.js UI implementation - uses structured ProjectContext
|
|
5
|
-
* Spec 131 - UI Project Context Visibility
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as React from 'react';
|
|
9
|
-
import { BookOpen, Settings, FileText, Copy, Check, AlertCircle, Coins, Info, Search, X } from 'lucide-react';
|
|
10
|
-
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Button, Input } from '@leanspec/ui-components';
|
|
11
|
-
import type { ProjectContext, ContextFile } from '../../types/api';
|
|
12
|
-
import { cn } from '../../lib/utils';
|
|
13
|
-
import { useTranslation } from 'react-i18next';
|
|
14
|
-
import { ContextFileDetail } from './ContextFileDetail';
|
|
15
|
-
import { PageHeader } from '../shared/PageHeader';
|
|
16
|
-
|
|
17
|
-
interface ContextClientProps {
|
|
18
|
-
context: ProjectContext;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Get token threshold color
|
|
23
|
-
*/
|
|
24
|
-
function getTotalTokenColor(tokens: number): string {
|
|
25
|
-
if (tokens < 5000) return 'text-green-600 dark:text-green-400';
|
|
26
|
-
if (tokens < 10000) return 'text-blue-600 dark:text-blue-400';
|
|
27
|
-
if (tokens < 20000) return 'text-yellow-600 dark:text-yellow-400';
|
|
28
|
-
return 'text-red-600 dark:text-red-400';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Count matches in content
|
|
33
|
-
*/
|
|
34
|
-
function countMatches(content: string, query: string): number {
|
|
35
|
-
if (!query || query.length < 2) return 0;
|
|
36
|
-
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
37
|
-
const matches = content.match(regex);
|
|
38
|
-
return matches ? matches.length : 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Empty state component
|
|
43
|
-
*/
|
|
44
|
-
function EmptyState({
|
|
45
|
-
icon: Icon,
|
|
46
|
-
title,
|
|
47
|
-
description,
|
|
48
|
-
suggestion
|
|
49
|
-
}: {
|
|
50
|
-
icon: React.ComponentType<{ className?: string }>;
|
|
51
|
-
title: string;
|
|
52
|
-
description: string;
|
|
53
|
-
suggestion?: string;
|
|
54
|
-
}) {
|
|
55
|
-
return (
|
|
56
|
-
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
57
|
-
<Icon className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
|
58
|
-
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
|
59
|
-
<p className="text-xs text-muted-foreground/70 mt-1 max-w-xs">{description}</p>
|
|
60
|
-
{suggestion && (
|
|
61
|
-
<p className="text-xs text-primary mt-2">{suggestion}</p>
|
|
62
|
-
)}
|
|
63
|
-
</div>
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Simple file card for list view
|
|
69
|
-
*/
|
|
70
|
-
function FileCard({
|
|
71
|
-
file,
|
|
72
|
-
searchQuery,
|
|
73
|
-
onClick,
|
|
74
|
-
}: {
|
|
75
|
-
file: ContextFile;
|
|
76
|
-
searchQuery?: string;
|
|
77
|
-
onClick: () => void;
|
|
78
|
-
}) {
|
|
79
|
-
const { t } = useTranslation('common');
|
|
80
|
-
const matches = searchQuery ? countMatches(file.content, searchQuery) : 0;
|
|
81
|
-
const hasMatch = !searchQuery || matches > 0 || file.name.toLowerCase().includes(searchQuery.toLowerCase());
|
|
82
|
-
|
|
83
|
-
if (!hasMatch) return null;
|
|
84
|
-
|
|
85
|
-
const matchesLabel = matches === 1
|
|
86
|
-
? t('contextPage.badges.matchesSingular', { count: matches })
|
|
87
|
-
: t('contextPage.badges.matchesPlural', { count: matches });
|
|
88
|
-
|
|
89
|
-
const tokensLabel = t('contextPage.badges.tokens', { formattedCount: file.tokenCount.toLocaleString() });
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<button
|
|
93
|
-
onClick={onClick}
|
|
94
|
-
className="w-full text-left p-4 rounded-lg border border-border bg-card hover:bg-accent transition-colors"
|
|
95
|
-
>
|
|
96
|
-
<div className="flex items-center justify-between gap-3">
|
|
97
|
-
<div className="flex items-center gap-2 min-w-0">
|
|
98
|
-
<FileText className="h-4 w-4 text-primary shrink-0" />
|
|
99
|
-
<div className="min-w-0">
|
|
100
|
-
<div className="text-sm font-medium truncate">{file.name}</div>
|
|
101
|
-
<div className="text-xs text-muted-foreground truncate">{file.path}</div>
|
|
102
|
-
</div>
|
|
103
|
-
</div>
|
|
104
|
-
<div className="flex items-center gap-2 shrink-0">
|
|
105
|
-
{searchQuery && matches > 0 && (
|
|
106
|
-
<Badge variant="secondary" className="text-xs bg-yellow-100 dark:bg-yellow-900">
|
|
107
|
-
{matchesLabel}
|
|
108
|
-
</Badge>
|
|
109
|
-
)}
|
|
110
|
-
<Badge variant="outline" className="text-xs">
|
|
111
|
-
<Coins className="h-3 w-3 mr-1" />
|
|
112
|
-
{tokensLabel}
|
|
113
|
-
</Badge>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
</button>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Section with collapsible file cards
|
|
122
|
-
*/
|
|
123
|
-
function ContextSection({
|
|
124
|
-
title,
|
|
125
|
-
description,
|
|
126
|
-
icon: Icon,
|
|
127
|
-
files,
|
|
128
|
-
emptyMessage,
|
|
129
|
-
emptySuggestion,
|
|
130
|
-
searchQuery,
|
|
131
|
-
onFileSelect,
|
|
132
|
-
}: {
|
|
133
|
-
title: string;
|
|
134
|
-
description: string;
|
|
135
|
-
icon: React.ComponentType<{ className?: string }>;
|
|
136
|
-
files: ContextFile[];
|
|
137
|
-
emptyMessage: string;
|
|
138
|
-
emptySuggestion?: string;
|
|
139
|
-
searchQuery?: string;
|
|
140
|
-
onFileSelect?: (file: ContextFile) => void;
|
|
141
|
-
}) {
|
|
142
|
-
const { t } = useTranslation('common');
|
|
143
|
-
const totalTokens = files.reduce((sum, f) => sum + f.tokenCount, 0);
|
|
144
|
-
|
|
145
|
-
// Filter files by search if query exists
|
|
146
|
-
const filteredFiles = React.useMemo(() => {
|
|
147
|
-
if (!searchQuery || searchQuery.length < 2) return files;
|
|
148
|
-
return files.filter(file => {
|
|
149
|
-
const matches = countMatches(file.content, searchQuery);
|
|
150
|
-
const nameMatch = file.name.toLowerCase().includes(searchQuery.toLowerCase());
|
|
151
|
-
return matches > 0 || nameMatch;
|
|
152
|
-
});
|
|
153
|
-
}, [files, searchQuery]);
|
|
154
|
-
|
|
155
|
-
// Calculate total matches in this section
|
|
156
|
-
const totalMatches = React.useMemo(() => {
|
|
157
|
-
if (!searchQuery || searchQuery.length < 2) return 0;
|
|
158
|
-
return filteredFiles.reduce((sum, file) => sum + countMatches(file.content, searchQuery), 0);
|
|
159
|
-
}, [filteredFiles, searchQuery]);
|
|
160
|
-
|
|
161
|
-
const filteredCount = filteredFiles.length;
|
|
162
|
-
|
|
163
|
-
const totalMatchesLabel = React.useMemo(() => (
|
|
164
|
-
totalMatches === 1
|
|
165
|
-
? t('contextPage.badges.matchesSingular', { count: totalMatches })
|
|
166
|
-
: t('contextPage.badges.matchesPlural', { count: totalMatches })
|
|
167
|
-
), [t, totalMatches]);
|
|
168
|
-
|
|
169
|
-
const filesLabel = React.useMemo(() => {
|
|
170
|
-
if (filteredCount === files.length) {
|
|
171
|
-
return filteredCount === 1
|
|
172
|
-
? t('contextPage.badges.filesSingular', { count: filteredCount })
|
|
173
|
-
: t('contextPage.badges.filesPlural', { count: filteredCount });
|
|
174
|
-
}
|
|
175
|
-
return t('contextPage.badges.filesFiltered', { count: filteredCount, total: files.length });
|
|
176
|
-
}, [files.length, filteredCount, t]);
|
|
177
|
-
|
|
178
|
-
const tokensLabel = React.useMemo(
|
|
179
|
-
() => t('contextPage.badges.tokens', { formattedCount: totalTokens.toLocaleString() }),
|
|
180
|
-
[t, totalTokens]
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
const emptyTitle = searchQuery
|
|
184
|
-
? t('contextPage.search.noMatchesTitle')
|
|
185
|
-
: emptyMessage;
|
|
186
|
-
|
|
187
|
-
const emptyDescription = searchQuery
|
|
188
|
-
? t('contextPage.search.noMatchesDescription')
|
|
189
|
-
: t('contextPage.search.noFilesDescription');
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<Card>
|
|
193
|
-
<CardHeader className="pb-3">
|
|
194
|
-
<div className="flex items-center justify-between">
|
|
195
|
-
<div className="flex items-center gap-3">
|
|
196
|
-
<div className="p-2 rounded-lg bg-primary/10">
|
|
197
|
-
<Icon className="h-5 w-5 text-primary" />
|
|
198
|
-
</div>
|
|
199
|
-
<div>
|
|
200
|
-
<CardTitle className="text-lg">{title}</CardTitle>
|
|
201
|
-
<CardDescription>{description}</CardDescription>
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
{files.length > 0 && (
|
|
205
|
-
<div className="flex items-center gap-2">
|
|
206
|
-
{searchQuery && totalMatches > 0 && (
|
|
207
|
-
<Badge variant="secondary" className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
|
|
208
|
-
{totalMatchesLabel}
|
|
209
|
-
</Badge>
|
|
210
|
-
)}
|
|
211
|
-
<Badge variant="secondary" className="text-xs">
|
|
212
|
-
{filesLabel}
|
|
213
|
-
</Badge>
|
|
214
|
-
<Badge variant="outline" className={cn('text-xs', getTotalTokenColor(totalTokens))}>
|
|
215
|
-
<Coins className="h-3 w-3 mr-1" />
|
|
216
|
-
{tokensLabel}
|
|
217
|
-
</Badge>
|
|
218
|
-
</div>
|
|
219
|
-
)}
|
|
220
|
-
</div>
|
|
221
|
-
</CardHeader>
|
|
222
|
-
<CardContent>
|
|
223
|
-
{filteredFiles.length === 0 ? (
|
|
224
|
-
<EmptyState
|
|
225
|
-
icon={AlertCircle}
|
|
226
|
-
title={emptyTitle}
|
|
227
|
-
description={emptyDescription}
|
|
228
|
-
suggestion={searchQuery ? undefined : emptySuggestion}
|
|
229
|
-
/>
|
|
230
|
-
) : (
|
|
231
|
-
<div className="space-y-3">
|
|
232
|
-
{filteredFiles.map((file) => (
|
|
233
|
-
<FileCard
|
|
234
|
-
key={file.path}
|
|
235
|
-
file={file}
|
|
236
|
-
searchQuery={searchQuery}
|
|
237
|
-
onClick={() => onFileSelect?.(file)}
|
|
238
|
-
/>
|
|
239
|
-
))}
|
|
240
|
-
</div>
|
|
241
|
-
)}
|
|
242
|
-
</CardContent>
|
|
243
|
-
</Card>
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function ContextClient({ context }: ContextClientProps) {
|
|
248
|
-
const [copiedAll, setCopiedAll] = React.useState(false);
|
|
249
|
-
const [searchQuery, setSearchQuery] = React.useState('');
|
|
250
|
-
const [selectedFile, setSelectedFile] = React.useState<ContextFile | null>(null);
|
|
251
|
-
const { t } = useTranslation('common');
|
|
252
|
-
|
|
253
|
-
// Collect all content for "Copy All" feature
|
|
254
|
-
const handleCopyAll = async () => {
|
|
255
|
-
const allContent: string[] = [];
|
|
256
|
-
|
|
257
|
-
// Agent instructions
|
|
258
|
-
for (const file of context.agentInstructions) {
|
|
259
|
-
allContent.push(`# ${file.path}\n\n${file.content}\n`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Config
|
|
263
|
-
if (context.config.file) {
|
|
264
|
-
allContent.push(`# ${context.config.file.path}\n\n${context.config.file.content}\n`);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Project docs
|
|
268
|
-
for (const file of context.projectDocs) {
|
|
269
|
-
allContent.push(`# ${file.path}\n\n${file.content}\n`);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
try {
|
|
273
|
-
await navigator.clipboard.writeText(allContent.join('\n---\n\n'));
|
|
274
|
-
setCopiedAll(true);
|
|
275
|
-
setTimeout(() => setCopiedAll(false), 2000);
|
|
276
|
-
} catch (error) {
|
|
277
|
-
console.error('Failed to copy all content:', error);
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const hasAnyContent =
|
|
282
|
-
context.agentInstructions.length > 0 ||
|
|
283
|
-
context.config.file !== null ||
|
|
284
|
-
context.projectDocs.length > 0;
|
|
285
|
-
|
|
286
|
-
// Calculate total matches across all files
|
|
287
|
-
const totalMatches = React.useMemo(() => {
|
|
288
|
-
if (!searchQuery || searchQuery.length < 2) return 0;
|
|
289
|
-
let total = 0;
|
|
290
|
-
for (const file of context.agentInstructions) {
|
|
291
|
-
total += countMatches(file.content, searchQuery);
|
|
292
|
-
}
|
|
293
|
-
if (context.config.file) {
|
|
294
|
-
total += countMatches(context.config.file.content, searchQuery);
|
|
295
|
-
}
|
|
296
|
-
for (const file of context.projectDocs) {
|
|
297
|
-
total += countMatches(file.content, searchQuery);
|
|
298
|
-
}
|
|
299
|
-
return total;
|
|
300
|
-
}, [context, searchQuery]);
|
|
301
|
-
|
|
302
|
-
// Convert ContextFile to the format expected by ContextFileDetail
|
|
303
|
-
const selectedFileForDetail = selectedFile ? {
|
|
304
|
-
name: selectedFile.name,
|
|
305
|
-
path: selectedFile.path,
|
|
306
|
-
content: selectedFile.content,
|
|
307
|
-
size: new Blob([selectedFile.content]).size,
|
|
308
|
-
tokenCount: selectedFile.tokenCount,
|
|
309
|
-
lineCount: selectedFile.content.split('\n').length,
|
|
310
|
-
modified: selectedFile.lastModified instanceof Date ? selectedFile.lastModified.toISOString() : selectedFile.lastModified,
|
|
311
|
-
} : null;
|
|
312
|
-
|
|
313
|
-
// If a file is selected, show the detail view
|
|
314
|
-
if (selectedFile && selectedFileForDetail) {
|
|
315
|
-
return (
|
|
316
|
-
<div className="min-h-screen bg-background">
|
|
317
|
-
<ContextFileDetail
|
|
318
|
-
file={selectedFileForDetail}
|
|
319
|
-
projectRoot={context.projectRoot}
|
|
320
|
-
onBack={() => setSelectedFile(null)}
|
|
321
|
-
/>
|
|
322
|
-
</div>
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return (
|
|
327
|
-
<div className="space-y-6 p-6">
|
|
328
|
-
<PageHeader
|
|
329
|
-
title={t('contextPage.title')}
|
|
330
|
-
description={t('contextPage.description')}
|
|
331
|
-
actions={hasAnyContent ? (
|
|
332
|
-
<Button
|
|
333
|
-
variant="outline"
|
|
334
|
-
size="sm"
|
|
335
|
-
onClick={handleCopyAll}
|
|
336
|
-
className="shrink-0"
|
|
337
|
-
>
|
|
338
|
-
{copiedAll ? (
|
|
339
|
-
<>
|
|
340
|
-
<Check className="h-4 w-4 mr-2 text-green-600" />
|
|
341
|
-
{t('contextPage.copyAllSuccess')}
|
|
342
|
-
</>
|
|
343
|
-
) : (
|
|
344
|
-
<>
|
|
345
|
-
<Copy className="h-4 w-4 mr-2" />
|
|
346
|
-
{t('contextPage.copyAll')}
|
|
347
|
-
</>
|
|
348
|
-
)}
|
|
349
|
-
</Button>
|
|
350
|
-
) : undefined}
|
|
351
|
-
/>
|
|
352
|
-
|
|
353
|
-
{/* Search and Summary */}
|
|
354
|
-
{hasAnyContent && (
|
|
355
|
-
<div className="space-y-4">
|
|
356
|
-
{/* Search bar */}
|
|
357
|
-
<div className="relative">
|
|
358
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
359
|
-
<Input
|
|
360
|
-
type="text"
|
|
361
|
-
placeholder={t('contextPage.searchPlaceholder')}
|
|
362
|
-
value={searchQuery}
|
|
363
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
364
|
-
className="pl-10 pr-10"
|
|
365
|
-
/>
|
|
366
|
-
{searchQuery && (
|
|
367
|
-
<Button
|
|
368
|
-
variant="ghost"
|
|
369
|
-
size="sm"
|
|
370
|
-
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
|
371
|
-
onClick={() => setSearchQuery('')}
|
|
372
|
-
>
|
|
373
|
-
<X className="h-4 w-4" />
|
|
374
|
-
</Button>
|
|
375
|
-
)}
|
|
376
|
-
</div>
|
|
377
|
-
|
|
378
|
-
{/* Search results info */}
|
|
379
|
-
{searchQuery && searchQuery.length >= 2 && (
|
|
380
|
-
<div className="flex items-center gap-2 text-sm">
|
|
381
|
-
{totalMatches > 0 ? (
|
|
382
|
-
<Badge variant="secondary" className="bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
|
|
383
|
-
{totalMatches === 1
|
|
384
|
-
? t('contextPage.badges.matchesFoundSingular', { count: totalMatches })
|
|
385
|
-
: t('contextPage.badges.matchesFoundPlural', { count: totalMatches })}
|
|
386
|
-
</Badge>
|
|
387
|
-
) : (
|
|
388
|
-
<span className="text-muted-foreground">
|
|
389
|
-
{t('contextPage.searchNoMatches', { query: searchQuery })}
|
|
390
|
-
</span>
|
|
391
|
-
)}
|
|
392
|
-
</div>
|
|
393
|
-
)}
|
|
394
|
-
|
|
395
|
-
{/* Summary card */}
|
|
396
|
-
<Card className="bg-muted/30">
|
|
397
|
-
<CardContent className="py-4">
|
|
398
|
-
<div className="flex flex-wrap items-center gap-4 sm:gap-8">
|
|
399
|
-
<div className="flex items-center gap-2">
|
|
400
|
-
<Coins className={cn('h-5 w-5', getTotalTokenColor(context.totalTokens))} />
|
|
401
|
-
<span className="text-sm">
|
|
402
|
-
<strong className={getTotalTokenColor(context.totalTokens)}>
|
|
403
|
-
{t('contextPage.summary.totalTokens', { formattedCount: context.totalTokens.toLocaleString() })}
|
|
404
|
-
</strong>
|
|
405
|
-
</span>
|
|
406
|
-
</div>
|
|
407
|
-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
408
|
-
<Info className="h-4 w-4" />
|
|
409
|
-
{t('contextPage.summary.contextBudget')}
|
|
410
|
-
</div>
|
|
411
|
-
</div>
|
|
412
|
-
</CardContent>
|
|
413
|
-
</Card>
|
|
414
|
-
</div>
|
|
415
|
-
)}
|
|
416
|
-
|
|
417
|
-
{/* Content sections */}
|
|
418
|
-
<div className="space-y-6">
|
|
419
|
-
{/* Agent Instructions */}
|
|
420
|
-
<ContextSection
|
|
421
|
-
title={t('contextPage.sections.agents.title')}
|
|
422
|
-
description={t('contextPage.sections.agents.description')}
|
|
423
|
-
icon={BookOpen}
|
|
424
|
-
files={context.agentInstructions}
|
|
425
|
-
emptyMessage={t('contextPage.sections.agents.empty')}
|
|
426
|
-
emptySuggestion={t('contextPage.sections.agents.suggestion')}
|
|
427
|
-
searchQuery={searchQuery}
|
|
428
|
-
onFileSelect={setSelectedFile}
|
|
429
|
-
/>
|
|
430
|
-
|
|
431
|
-
{/* Configuration */}
|
|
432
|
-
<ContextSection
|
|
433
|
-
title={t('contextPage.sections.config.title')}
|
|
434
|
-
description={t('contextPage.sections.config.description')}
|
|
435
|
-
icon={Settings}
|
|
436
|
-
files={context.config.file ? [context.config.file] : []}
|
|
437
|
-
emptyMessage={t('contextPage.sections.config.empty')}
|
|
438
|
-
emptySuggestion={t('contextPage.sections.config.suggestion')}
|
|
439
|
-
searchQuery={searchQuery}
|
|
440
|
-
onFileSelect={setSelectedFile}
|
|
441
|
-
/>
|
|
442
|
-
|
|
443
|
-
{/* Project Documentation */}
|
|
444
|
-
<ContextSection
|
|
445
|
-
title={t('contextPage.sections.docs.title')}
|
|
446
|
-
description={t('contextPage.sections.docs.description')}
|
|
447
|
-
icon={FileText}
|
|
448
|
-
files={context.projectDocs}
|
|
449
|
-
emptyMessage={t('contextPage.sections.docs.empty')}
|
|
450
|
-
emptySuggestion={t('contextPage.sections.docs.suggestion')}
|
|
451
|
-
searchQuery={searchQuery}
|
|
452
|
-
onFileSelect={setSelectedFile}
|
|
453
|
-
/>
|
|
454
|
-
</div>
|
|
455
|
-
|
|
456
|
-
{/* No content state */}
|
|
457
|
-
{!hasAnyContent && (
|
|
458
|
-
<Card className="mt-8">
|
|
459
|
-
<CardContent className="py-12">
|
|
460
|
-
<EmptyState
|
|
461
|
-
icon={BookOpen}
|
|
462
|
-
title={t('contextPage.emptyState.title')}
|
|
463
|
-
description={t('contextPage.emptyState.description')}
|
|
464
|
-
suggestion={t('contextPage.emptyState.suggestion')}
|
|
465
|
-
/>
|
|
466
|
-
</CardContent>
|
|
467
|
-
</Card>
|
|
468
|
-
)}
|
|
469
|
-
</div>
|
|
470
|
-
);
|
|
471
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
2
|
-
import ReactMarkdown from 'react-markdown';
|
|
3
|
-
import remarkGfm from 'remark-gfm';
|
|
4
|
-
import rehypeHighlight from 'rehype-highlight';
|
|
5
|
-
import rehypeSlug from 'rehype-slug';
|
|
6
|
-
import { ArrowLeft, Clock, Copy, ExternalLink, FileText, Hash, Layers, Type } from 'lucide-react';
|
|
7
|
-
import { Badge, Button } from '@leanspec/ui-components';
|
|
8
|
-
import { TableOfContents, TableOfContentsSidebar } from '../spec-detail/TableOfContents';
|
|
9
|
-
import { MermaidDiagram } from '../MermaidDiagram';
|
|
10
|
-
import type { ContextFileContent } from '../../types/api';
|
|
11
|
-
import { useTranslation } from 'react-i18next';
|
|
12
|
-
import { formatDate } from '../../lib/date-utils';
|
|
13
|
-
|
|
14
|
-
interface ContextFileDetailProps {
|
|
15
|
-
file: ContextFileContent;
|
|
16
|
-
projectRoot?: string;
|
|
17
|
-
onBack?: () => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function toVSCodeUri(projectRoot: string, filePath: string) {
|
|
21
|
-
const fullPath = filePath.startsWith('/') ? filePath : `${projectRoot}/${filePath}`;
|
|
22
|
-
return `vscode://file${fullPath}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function ContextFileDetail({ file, projectRoot, onBack }: ContextFileDetailProps) {
|
|
26
|
-
const [copied, setCopied] = useState(false);
|
|
27
|
-
const isMarkdown = file.name.toLowerCase().endsWith('.md');
|
|
28
|
-
const isJson = file.name.toLowerCase().endsWith('.json');
|
|
29
|
-
const { t, i18n } = useTranslation('common');
|
|
30
|
-
|
|
31
|
-
const headingContent = useMemo(() => file.content, [file.content]);
|
|
32
|
-
|
|
33
|
-
const handleCopy = async () => {
|
|
34
|
-
try {
|
|
35
|
-
await navigator.clipboard.writeText(file.content);
|
|
36
|
-
setCopied(true);
|
|
37
|
-
setTimeout(() => setCopied(false), 1500);
|
|
38
|
-
} catch (err) {
|
|
39
|
-
console.error('Failed to copy context content', err);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const handleOpenInEditor = () => {
|
|
44
|
-
if (!projectRoot) return;
|
|
45
|
-
window.open(toVSCodeUri(projectRoot, file.path), '_blank');
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<div className="space-y-6 px-6 py-3">
|
|
50
|
-
<header className="flex flex-col gap-3 sticky top-14 bg-background py-2 z-10">
|
|
51
|
-
<div className="flex items-center justify-between gap-3">
|
|
52
|
-
<div className="flex items-center gap-2">
|
|
53
|
-
{onBack && (
|
|
54
|
-
<Button variant="ghost" size="sm" onClick={onBack} className="h-8 px-2">
|
|
55
|
-
<ArrowLeft className="h-4 w-4 mr-1" />
|
|
56
|
-
{t('contextPage.detail.back')}
|
|
57
|
-
</Button>
|
|
58
|
-
)}
|
|
59
|
-
<FileText className="h-5 w-5 text-primary" />
|
|
60
|
-
<div className="flex flex-col">
|
|
61
|
-
<h2 className="text-lg sm:text-xl font-semibold leading-tight break-words">{file.name}</h2>
|
|
62
|
-
<span className="text-xs text-muted-foreground break-all">{file.path}</span>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
65
|
-
<div className="flex items-center gap-2">
|
|
66
|
-
{projectRoot && (
|
|
67
|
-
<Button variant="ghost" size="sm" onClick={handleOpenInEditor} className="h-8 px-3 text-xs">
|
|
68
|
-
<ExternalLink className="h-3.5 w-3.5 mr-1" />
|
|
69
|
-
{t('contextPage.detail.openInEditor')}
|
|
70
|
-
</Button>
|
|
71
|
-
)}
|
|
72
|
-
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-8 px-3 text-xs">
|
|
73
|
-
{copied ? <Hash className="h-3.5 w-3.5 mr-1 text-green-600" /> : <Copy className="h-3.5 w-3.5 mr-1" />}
|
|
74
|
-
{copied ? t('contextPage.detail.copySuccess') : t('contextPage.detail.copy')}
|
|
75
|
-
</Button>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
79
|
-
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
|
80
|
-
<Type className="h-3 w-3" />
|
|
81
|
-
{file.fileType || t('contextPage.detail.defaultFileType')}
|
|
82
|
-
</Badge>
|
|
83
|
-
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
|
84
|
-
<Layers className="h-3 w-3" />
|
|
85
|
-
{t('contextPage.badges.tokens', { formattedCount: file.tokenCount.toLocaleString() })}
|
|
86
|
-
</Badge>
|
|
87
|
-
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
|
88
|
-
<FileText className="h-3 w-3" />
|
|
89
|
-
{t('contextPage.detail.lines', { count: file.lineCount })}
|
|
90
|
-
</Badge>
|
|
91
|
-
<span className="flex items-center gap-1">
|
|
92
|
-
<Clock className="h-3.5 w-3.5" />
|
|
93
|
-
{t('contextPage.detail.modified', {
|
|
94
|
-
date: formatDate(file.modified ?? file.modifiedAt, i18n.language),
|
|
95
|
-
})}
|
|
96
|
-
</span>
|
|
97
|
-
<span className="text-muted-foreground">•</span>
|
|
98
|
-
<span>{t('contextPage.detail.size', { size: (file.size / 1024).toFixed(1) })}</span>
|
|
99
|
-
</div>
|
|
100
|
-
</header>
|
|
101
|
-
|
|
102
|
-
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
|
103
|
-
<div className="overflow-hidden p-4 sm:p-6">
|
|
104
|
-
{isJson ? (
|
|
105
|
-
<pre className="p-3 text-sm overflow-x-auto bg-muted/40 rounded-md border whitespace-pre-wrap">
|
|
106
|
-
{(() => {
|
|
107
|
-
try {
|
|
108
|
-
return JSON.stringify(JSON.parse(file.content), null, 2);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
console.error('Failed to parse JSON context file', err);
|
|
111
|
-
return file.content;
|
|
112
|
-
}
|
|
113
|
-
})()}
|
|
114
|
-
</pre>
|
|
115
|
-
) : isMarkdown ? (
|
|
116
|
-
<article className="prose prose-slate dark:prose-invert max-w-none prose-sm sm:prose-base">
|
|
117
|
-
<ReactMarkdown
|
|
118
|
-
remarkPlugins={[remarkGfm]}
|
|
119
|
-
rehypePlugins={[rehypeHighlight, rehypeSlug]}
|
|
120
|
-
components={{
|
|
121
|
-
pre: ({ children, ...props }) => {
|
|
122
|
-
const childArray = Array.isArray(children) ? children : [children];
|
|
123
|
-
const firstChild = childArray[0];
|
|
124
|
-
if (
|
|
125
|
-
firstChild &&
|
|
126
|
-
typeof firstChild === 'object' &&
|
|
127
|
-
'props' in firstChild &&
|
|
128
|
-
typeof (firstChild as { props?: { className?: string; children?: string } }).props?.className === 'string' &&
|
|
129
|
-
(firstChild as { props?: { className?: string } }).props?.className?.includes('language-mermaid')
|
|
130
|
-
) {
|
|
131
|
-
const code = (firstChild as { props?: { children?: string } }).props?.children;
|
|
132
|
-
const content = typeof code === 'string' ? code : '';
|
|
133
|
-
return <MermaidDiagram chart={content} />;
|
|
134
|
-
}
|
|
135
|
-
return <pre {...props}>{children}</pre>;
|
|
136
|
-
},
|
|
137
|
-
}}
|
|
138
|
-
>
|
|
139
|
-
{file.content}
|
|
140
|
-
</ReactMarkdown>
|
|
141
|
-
</article>
|
|
142
|
-
) : (
|
|
143
|
-
<pre className="p-3 text-sm overflow-x-auto bg-muted/40 rounded-md border whitespace-pre-wrap">
|
|
144
|
-
{file.content}
|
|
145
|
-
</pre>
|
|
146
|
-
)}
|
|
147
|
-
</div>
|
|
148
|
-
|
|
149
|
-
{isMarkdown && (
|
|
150
|
-
<aside className="hidden xl:block sticky top-28 h-[calc(100vh-8rem)] overflow-y-auto">
|
|
151
|
-
<TableOfContentsSidebar content={headingContent} />
|
|
152
|
-
</aside>
|
|
153
|
-
)}
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
{isMarkdown && (
|
|
157
|
-
<div className="xl:hidden">
|
|
158
|
-
<TableOfContents content={headingContent} />
|
|
159
|
-
</div>
|
|
160
|
-
)}
|
|
161
|
-
</div>
|
|
162
|
-
);
|
|
163
|
-
}
|