@geminilight/mindos 0.5.21 → 0.5.23
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/app/app/api/ask/route.ts +31 -9
- package/app/app/api/bootstrap/route.ts +1 -0
- package/app/app/api/monitoring/route.ts +95 -0
- package/app/app/globals.css +14 -0
- package/app/app/setup/page.tsx +3 -2
- package/app/components/ActivityBar.tsx +183 -0
- package/app/components/AskFab.tsx +39 -97
- package/app/components/AskModal.tsx +13 -371
- package/app/components/Breadcrumb.tsx +4 -4
- package/app/components/FileTree.tsx +21 -4
- package/app/components/Logo.tsx +39 -0
- package/app/components/Panel.tsx +152 -0
- package/app/components/RightAskPanel.tsx +72 -0
- package/app/components/SettingsModal.tsx +9 -235
- package/app/components/SidebarLayout.tsx +426 -12
- package/app/components/SyncStatusBar.tsx +74 -53
- package/app/components/TableOfContents.tsx +4 -2
- package/app/components/ask/AskContent.tsx +418 -0
- package/app/components/ask/MessageList.tsx +2 -2
- package/app/components/panels/AgentsPanel.tsx +231 -0
- package/app/components/panels/PanelHeader.tsx +35 -0
- package/app/components/panels/PluginsPanel.tsx +106 -0
- package/app/components/panels/SearchPanel.tsx +178 -0
- package/app/components/panels/SyncPopover.tsx +105 -0
- package/app/components/renderers/csv/TableView.tsx +4 -4
- package/app/components/settings/AgentsTab.tsx +240 -0
- package/app/components/settings/AiTab.tsx +39 -1
- package/app/components/settings/KnowledgeTab.tsx +116 -2
- package/app/components/settings/McpTab.tsx +6 -6
- package/app/components/settings/MonitoringTab.tsx +202 -0
- package/app/components/settings/SettingsContent.tsx +343 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/setup/index.tsx +2 -23
- package/app/hooks/useResizeDrag.ts +78 -0
- package/app/instrumentation.ts +7 -2
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/model.ts +33 -10
- package/app/lib/api.ts +12 -3
- package/app/lib/core/csv.ts +2 -1
- package/app/lib/core/fs-ops.ts +7 -6
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/lines.ts +7 -6
- package/app/lib/core/search-index.ts +174 -0
- package/app/lib/core/search.ts +30 -1
- package/app/lib/core/security.ts +6 -3
- package/app/lib/errors.ts +108 -0
- package/app/lib/format.ts +19 -0
- package/app/lib/fs.ts +6 -3
- package/app/lib/i18n-en.ts +49 -6
- package/app/lib/i18n-zh.ts +48 -5
- package/app/lib/metrics.ts +81 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +27 -97
- package/package.json +4 -2
- package/scripts/setup.js +2 -12
- package/skills/mindos/SKILL.md +226 -8
- package/skills/mindos-zh/SKILL.md +226 -8
- package/app/package-lock.json +0 -15736
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Maximize2, Minimize2 } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared header bar for side panels (Files, Search, Plugins, etc.)
|
|
5
|
+
* Keeps the uppercase label + optional right content pattern consistent.
|
|
6
|
+
*/
|
|
7
|
+
export default function PanelHeader({
|
|
8
|
+
title,
|
|
9
|
+
children,
|
|
10
|
+
maximized,
|
|
11
|
+
onMaximize,
|
|
12
|
+
}: {
|
|
13
|
+
title: string;
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
maximized?: boolean;
|
|
16
|
+
onMaximize?: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
|
20
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider font-display">{title}</span>
|
|
21
|
+
<div className="flex items-center gap-1">
|
|
22
|
+
{children}
|
|
23
|
+
{onMaximize && (
|
|
24
|
+
<button
|
|
25
|
+
onClick={onMaximize}
|
|
26
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
27
|
+
aria-label={maximized ? 'Restore panel' : 'Maximize panel'}
|
|
28
|
+
>
|
|
29
|
+
{maximized ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
|
|
30
|
+
</button>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { getAllRenderers, isRendererEnabled, setRendererEnabled, loadDisabledState } from '@/lib/renderers/registry';
|
|
6
|
+
import { Toggle } from '../settings/Primitives';
|
|
7
|
+
import PanelHeader from './PanelHeader';
|
|
8
|
+
|
|
9
|
+
interface PluginsPanelProps {
|
|
10
|
+
active: boolean;
|
|
11
|
+
maximized?: boolean;
|
|
12
|
+
onMaximize?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function PluginsPanel({ active, maximized, onMaximize }: PluginsPanelProps) {
|
|
16
|
+
const [mounted, setMounted] = useState(false);
|
|
17
|
+
const [, forceUpdate] = useState(0);
|
|
18
|
+
const router = useRouter();
|
|
19
|
+
|
|
20
|
+
// Defer renderer reads to client only — avoids hydration mismatch
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
loadDisabledState();
|
|
23
|
+
setMounted(true);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const renderers = mounted ? getAllRenderers() : [];
|
|
27
|
+
const enabledCount = mounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
|
|
28
|
+
|
|
29
|
+
const handleToggle = (id: string, enabled: boolean) => {
|
|
30
|
+
setRendererEnabled(id, enabled);
|
|
31
|
+
forceUpdate(n => n + 1);
|
|
32
|
+
window.dispatchEvent(new Event('renderer-state-changed'));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
|
|
37
|
+
{/* Header */}
|
|
38
|
+
<PanelHeader title="Plugins" maximized={maximized} onMaximize={onMaximize}>
|
|
39
|
+
<span className="text-2xs text-muted-foreground">{enabledCount}/{renderers.length} active</span>
|
|
40
|
+
</PanelHeader>
|
|
41
|
+
|
|
42
|
+
{/* Plugin list */}
|
|
43
|
+
<div className="flex-1 overflow-y-auto min-h-0">
|
|
44
|
+
{mounted && renderers.length === 0 && (
|
|
45
|
+
<p className="px-4 py-8 text-sm text-muted-foreground text-center">No plugins registered</p>
|
|
46
|
+
)}
|
|
47
|
+
{renderers.map(r => {
|
|
48
|
+
const enabled = isRendererEnabled(r.id);
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
key={r.id}
|
|
52
|
+
className="px-4 py-3 border-b border-border/50 hover:bg-muted/30 transition-colors"
|
|
53
|
+
>
|
|
54
|
+
{/* Top row: icon + name + toggle */}
|
|
55
|
+
<div className="flex items-center justify-between gap-2">
|
|
56
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
57
|
+
<span className="text-base shrink-0">{r.icon}</span>
|
|
58
|
+
<span className="text-sm font-medium text-foreground truncate">{r.name}</span>
|
|
59
|
+
{r.core && (
|
|
60
|
+
<span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">Core</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
<Toggle
|
|
64
|
+
checked={enabled}
|
|
65
|
+
onChange={(v) => handleToggle(r.id, v)}
|
|
66
|
+
size="sm"
|
|
67
|
+
disabled={r.core}
|
|
68
|
+
title={r.core ? 'Core plugin — cannot be disabled' : undefined}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Description */}
|
|
73
|
+
<p className="mt-1 text-xs text-muted-foreground leading-relaxed pl-[30px]">
|
|
74
|
+
{r.description}
|
|
75
|
+
</p>
|
|
76
|
+
|
|
77
|
+
{/* Tags + entry path */}
|
|
78
|
+
<div className="mt-1.5 flex items-center gap-1.5 pl-[30px] flex-wrap">
|
|
79
|
+
{r.tags.slice(0, 3).map(tag => (
|
|
80
|
+
<span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full bg-muted/60 text-muted-foreground">
|
|
81
|
+
{tag}
|
|
82
|
+
</span>
|
|
83
|
+
))}
|
|
84
|
+
{r.entryPath && enabled && (
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => router.push(`/view/${r.entryPath!.split('/').map(encodeURIComponent).join('/')}`)}
|
|
87
|
+
className="text-2xs px-1.5 py-0.5 rounded-full text-[var(--amber)] hover:bg-[var(--amber-dim)] transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
88
|
+
>
|
|
89
|
+
→ {r.entryPath}
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Footer info */}
|
|
99
|
+
<div className="px-4 py-2 border-t border-border shrink-0">
|
|
100
|
+
<p className="text-2xs text-muted-foreground">
|
|
101
|
+
Plugins customize how files render. Core plugins cannot be disabled.
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Search, X, FileText, Table } from 'lucide-react';
|
|
6
|
+
import { SearchResult } from '@/lib/types';
|
|
7
|
+
import { encodePath } from '@/lib/utils';
|
|
8
|
+
import { apiFetch } from '@/lib/api';
|
|
9
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
10
|
+
import PanelHeader from './PanelHeader';
|
|
11
|
+
|
|
12
|
+
/** Highlight matched text fragments in a snippet based on the query */
|
|
13
|
+
function highlightSnippet(snippet: string, query: string): React.ReactNode {
|
|
14
|
+
if (!query.trim()) return snippet;
|
|
15
|
+
const words = query.trim().split(/\s+/).filter(Boolean);
|
|
16
|
+
const escaped = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
17
|
+
const pattern = new RegExp(`(${escaped.join('|')})`, 'gi');
|
|
18
|
+
const parts = snippet.split(pattern);
|
|
19
|
+
return parts.map((part, i) =>
|
|
20
|
+
pattern.test(part) ? <mark key={i} className="bg-yellow-300/40 text-foreground rounded-sm px-0.5">{part}</mark> : part
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SearchPanelProps {
|
|
25
|
+
/** When true the panel is visible — triggers focus & reset */
|
|
26
|
+
active: boolean;
|
|
27
|
+
/** Called when user navigates to a result (panel host may want to close) */
|
|
28
|
+
onNavigate?: () => void;
|
|
29
|
+
maximized?: boolean;
|
|
30
|
+
onMaximize?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function SearchPanel({ active, onNavigate, maximized, onMaximize }: SearchPanelProps) {
|
|
34
|
+
const [query, setQuery] = useState('');
|
|
35
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
36
|
+
const [loading, setLoading] = useState(false);
|
|
37
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
38
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
const router = useRouter();
|
|
40
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
|
+
const { t } = useLocale();
|
|
42
|
+
|
|
43
|
+
// Focus input when panel becomes active
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (active) {
|
|
46
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
47
|
+
}
|
|
48
|
+
}, [active]);
|
|
49
|
+
|
|
50
|
+
// Debounced search
|
|
51
|
+
const doSearch = useCallback((q: string) => {
|
|
52
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
53
|
+
if (!q.trim()) {
|
|
54
|
+
setResults([]);
|
|
55
|
+
setLoading(false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setLoading(true);
|
|
59
|
+
debounceTimer.current = setTimeout(async () => {
|
|
60
|
+
try {
|
|
61
|
+
const data = await apiFetch<SearchResult[]>(`/api/search?q=${encodeURIComponent(q)}`);
|
|
62
|
+
setResults(Array.isArray(data) ? data : []);
|
|
63
|
+
setSelectedIndex(0);
|
|
64
|
+
} catch {
|
|
65
|
+
setResults([]);
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}, 300);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
73
|
+
const val = e.target.value;
|
|
74
|
+
setQuery(val);
|
|
75
|
+
doSearch(val);
|
|
76
|
+
}, [doSearch]);
|
|
77
|
+
|
|
78
|
+
const navigate = useCallback((result: SearchResult) => {
|
|
79
|
+
router.push(`/view/${encodePath(result.path)}`);
|
|
80
|
+
onNavigate?.();
|
|
81
|
+
}, [router, onNavigate]);
|
|
82
|
+
|
|
83
|
+
// Keyboard navigation within the panel
|
|
84
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
85
|
+
if (e.key === 'ArrowDown') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
setSelectedIndex(i => Math.min(i + 1, results.length - 1));
|
|
88
|
+
} else if (e.key === 'ArrowUp') {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
setSelectedIndex(i => Math.max(i - 1, 0));
|
|
91
|
+
} else if (e.key === 'Enter') {
|
|
92
|
+
if (results[selectedIndex]) navigate(results[selectedIndex]);
|
|
93
|
+
}
|
|
94
|
+
}, [results, selectedIndex, navigate]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<>
|
|
98
|
+
{/* Header */}
|
|
99
|
+
<PanelHeader title="Search" maximized={maximized} onMaximize={onMaximize} />
|
|
100
|
+
|
|
101
|
+
{/* Search input */}
|
|
102
|
+
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
|
|
103
|
+
<Search size={14} className="text-muted-foreground shrink-0" />
|
|
104
|
+
<input
|
|
105
|
+
ref={inputRef}
|
|
106
|
+
type="text"
|
|
107
|
+
value={query}
|
|
108
|
+
onChange={handleChange}
|
|
109
|
+
onKeyDown={handleKeyDown}
|
|
110
|
+
placeholder={t.search.placeholder}
|
|
111
|
+
className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground text-sm outline-none"
|
|
112
|
+
/>
|
|
113
|
+
{loading && (
|
|
114
|
+
<div className="w-3.5 h-3.5 border-2 border-muted-foreground/40 border-t-foreground rounded-full animate-spin shrink-0" />
|
|
115
|
+
)}
|
|
116
|
+
{!loading && query && (
|
|
117
|
+
<button onClick={() => { setQuery(''); setResults([]); inputRef.current?.focus(); }}>
|
|
118
|
+
<X size={13} className="text-muted-foreground hover:text-foreground" />
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Results */}
|
|
124
|
+
<div className="flex-1 overflow-y-auto min-h-0">
|
|
125
|
+
{results.length === 0 && query && !loading && (
|
|
126
|
+
<div className="px-4 py-8 text-center text-sm text-muted-foreground">{t.search.noResults}</div>
|
|
127
|
+
)}
|
|
128
|
+
{results.length === 0 && !query && (
|
|
129
|
+
<div className="px-4 py-8 text-center text-sm text-muted-foreground/60">{t.search.prompt}</div>
|
|
130
|
+
)}
|
|
131
|
+
{results.map((result, i) => {
|
|
132
|
+
const ext = result.path.endsWith('.csv') ? '.csv' : '.md';
|
|
133
|
+
const parts = result.path.split('/');
|
|
134
|
+
const fileName = parts[parts.length - 1];
|
|
135
|
+
const dirPath = parts.slice(0, -1).join('/');
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
key={result.path}
|
|
139
|
+
onClick={() => navigate(result)}
|
|
140
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
141
|
+
className={`
|
|
142
|
+
w-full px-4 py-2.5 flex items-start gap-3 text-left transition-colors duration-75
|
|
143
|
+
${i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'}
|
|
144
|
+
${i < results.length - 1 ? 'border-b border-border' : ''}
|
|
145
|
+
`}
|
|
146
|
+
>
|
|
147
|
+
{ext === '.csv'
|
|
148
|
+
? <Table size={13} className="text-success shrink-0 mt-0.5" />
|
|
149
|
+
: <FileText size={13} className="text-muted-foreground shrink-0 mt-0.5" />
|
|
150
|
+
}
|
|
151
|
+
<div className="min-w-0 flex-1">
|
|
152
|
+
<div className="flex items-baseline gap-2 flex-wrap">
|
|
153
|
+
<span className="text-sm text-foreground font-medium truncate">{fileName}</span>
|
|
154
|
+
{dirPath && (
|
|
155
|
+
<span className="text-xs text-muted-foreground truncate">{dirPath}</span>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
{result.snippet && (
|
|
159
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 leading-relaxed">
|
|
160
|
+
{highlightSnippet(result.snippet, query)}
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Footer hints */}
|
|
170
|
+
{results.length > 0 && (
|
|
171
|
+
<div className="px-4 py-2 border-t border-border flex items-center gap-3 text-xs text-muted-foreground/50 shrink-0">
|
|
172
|
+
<span><kbd className="font-mono">↑↓</kbd> {t.search.navigate}</span>
|
|
173
|
+
<span><kbd className="font-mono">↵</kbd> {t.search.open}</span>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { RefreshCw, CheckCircle2, XCircle, X } from 'lucide-react';
|
|
5
|
+
import { DOT_COLORS, getStatusLevel, getSyncLabel, useSyncAction } from '../SyncStatusBar';
|
|
6
|
+
import type { SyncStatus } from '../settings/SyncTab';
|
|
7
|
+
import { PrimaryButton } from '../settings/Primitives';
|
|
8
|
+
|
|
9
|
+
interface SyncPopoverProps {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
anchorRect: DOMRect | null;
|
|
13
|
+
railWidth: number;
|
|
14
|
+
onOpenSyncSettings: () => void;
|
|
15
|
+
syncStatus: SyncStatus | null;
|
|
16
|
+
onSyncStatusRefresh: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function SyncPopover({ open, onClose, anchorRect, railWidth, onOpenSyncSettings, syncStatus, onSyncStatusRefresh }: SyncPopoverProps) {
|
|
20
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
21
|
+
const onCloseRef = useRef(onClose);
|
|
22
|
+
onCloseRef.current = onClose;
|
|
23
|
+
const { syncing, syncResult, syncNow } = useSyncAction(onSyncStatusRefresh);
|
|
24
|
+
|
|
25
|
+
// Close on ESC
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!open) return;
|
|
28
|
+
const handler = (e: KeyboardEvent) => {
|
|
29
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCloseRef.current(); }
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener('keydown', handler);
|
|
32
|
+
return () => window.removeEventListener('keydown', handler);
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
// Close on click outside
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!open) return;
|
|
38
|
+
const handler = (e: MouseEvent) => {
|
|
39
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onCloseRef.current();
|
|
40
|
+
};
|
|
41
|
+
const id = setTimeout(() => window.addEventListener('mousedown', handler), 0);
|
|
42
|
+
return () => { clearTimeout(id); window.removeEventListener('mousedown', handler); };
|
|
43
|
+
}, [open]);
|
|
44
|
+
|
|
45
|
+
if (!open || !anchorRect) return null;
|
|
46
|
+
|
|
47
|
+
const level = getStatusLevel(syncStatus, syncing);
|
|
48
|
+
const { label: statusText } = getSyncLabel(level, syncStatus);
|
|
49
|
+
|
|
50
|
+
// Position: anchor near the button, avoid going off-screen top
|
|
51
|
+
const popoverTop = Math.max(8, anchorRect.bottom - 180);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
ref={ref}
|
|
56
|
+
className="fixed z-40 w-[240px] border rounded-lg bg-card shadow-lg border-border animate-in fade-in slide-in-from-left-2 duration-150"
|
|
57
|
+
style={{
|
|
58
|
+
top: popoverTop,
|
|
59
|
+
left: railWidth,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
|
63
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sync</span>
|
|
64
|
+
<button
|
|
65
|
+
onClick={onClose}
|
|
66
|
+
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
67
|
+
aria-label="Close"
|
|
68
|
+
>
|
|
69
|
+
<X size={14} />
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="p-3 space-y-3">
|
|
73
|
+
{/* Status */}
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${DOT_COLORS[level]} ${
|
|
76
|
+
level === 'syncing' || level === 'conflicts' || level === 'error' ? 'animate-pulse' : ''
|
|
77
|
+
}`} />
|
|
78
|
+
<span className="text-sm text-foreground">{statusText}</span>
|
|
79
|
+
{syncResult === 'success' && <CheckCircle2 size={14} className="text-success shrink-0" />}
|
|
80
|
+
{syncResult === 'error' && <XCircle size={14} className="text-error shrink-0" />}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Actions */}
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
{level !== 'off' && (
|
|
86
|
+
<PrimaryButton
|
|
87
|
+
onClick={syncNow}
|
|
88
|
+
disabled={syncing}
|
|
89
|
+
className="text-xs px-3 py-1.5 flex items-center gap-1.5"
|
|
90
|
+
>
|
|
91
|
+
<RefreshCw size={12} className={syncing ? 'animate-spin' : ''} />
|
|
92
|
+
Sync Now
|
|
93
|
+
</PrimaryButton>
|
|
94
|
+
)}
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => { onOpenSyncSettings(); onClose(); }}
|
|
97
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 py-0.5 focus-visible:ring-2 focus-visible:ring-ring"
|
|
98
|
+
>
|
|
99
|
+
Settings →
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo, useEffect } from 'react';
|
|
3
|
+
import React, { useState, useMemo, useEffect } from 'react';
|
|
4
4
|
import { ChevronUp, ChevronDown, Plus, Trash2 } from 'lucide-react';
|
|
5
5
|
import type { TableConfig } from './types';
|
|
6
6
|
import { serializeCSV } from './types';
|
|
@@ -102,8 +102,8 @@ export function TableView({ headers, rows, cfg, saveAction }: {
|
|
|
102
102
|
</tr>
|
|
103
103
|
</thead>
|
|
104
104
|
<tbody>
|
|
105
|
-
{sections.map(section => (
|
|
106
|
-
|
|
105
|
+
{sections.map((section, si) => (
|
|
106
|
+
<React.Fragment key={section.key ?? `section-${si}`}>
|
|
107
107
|
{section.key !== null && (
|
|
108
108
|
<tr key={`grp-${section.key}`}>
|
|
109
109
|
<td colSpan={visibleIndices.length + 1} className="px-4 py-1.5"
|
|
@@ -137,7 +137,7 @@ export function TableView({ headers, rows, cfg, saveAction }: {
|
|
|
137
137
|
</tr>
|
|
138
138
|
);
|
|
139
139
|
})}
|
|
140
|
-
|
|
140
|
+
</React.Fragment>
|
|
141
141
|
))}
|
|
142
142
|
{showAdd && (
|
|
143
143
|
<AddRowTr headers={headers} visibleIndices={visibleIndices} onAdd={addRow} onCancel={() => setShowAdd(false)} />
|