@geminilight/mindos 0.5.28 → 0.5.30
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/update/route.ts +41 -0
- package/app/app/explore/page.tsx +12 -0
- package/app/components/ActivityBar.tsx +14 -7
- package/app/components/GuideCard.tsx +21 -7
- package/app/components/HomeContent.tsx +31 -97
- package/app/components/KeyboardShortcuts.tsx +102 -0
- package/app/components/Panel.tsx +12 -7
- package/app/components/SidebarLayout.tsx +21 -1
- package/app/components/UpdateBanner.tsx +19 -21
- package/app/components/explore/ExploreContent.tsx +100 -0
- package/app/components/explore/UseCaseCard.tsx +50 -0
- package/app/components/explore/use-cases.ts +30 -0
- package/app/components/panels/AgentsPanel.tsx +268 -131
- package/app/components/panels/PluginsPanel.tsx +87 -27
- package/app/components/settings/AiTab.tsx +5 -3
- package/app/components/settings/McpSkillsSection.tsx +12 -0
- package/app/components/settings/McpTab.tsx +28 -30
- package/app/components/settings/SettingsContent.tsx +5 -2
- package/app/components/settings/UpdateTab.tsx +195 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
- package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
- package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
- package/app/components/walkthrough/index.ts +3 -0
- package/app/components/walkthrough/steps.ts +21 -0
- package/app/hooks/useMcpData.tsx +166 -0
- package/app/lib/i18n-en.ts +182 -5
- package/app/lib/i18n-zh.ts +181 -4
- package/app/lib/mcp-snippets.ts +103 -0
- package/app/lib/settings.ts +4 -0
- package/app/next-env.d.ts +1 -1
- package/app/package.json +1 -0
- package/package.json +1 -1
- package/app/components/settings/McpServerStatus.tsx +0 -274
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const dynamic = 'force-dynamic';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/update — trigger `mindos update` as a detached child process.
|
|
8
|
+
*
|
|
9
|
+
* Similar to /api/restart: spawns the CLI command and returns immediately.
|
|
10
|
+
* The update process will npm install, remove build stamp, and restart
|
|
11
|
+
* the server. The current process will be killed during restart.
|
|
12
|
+
*/
|
|
13
|
+
export async function POST() {
|
|
14
|
+
try {
|
|
15
|
+
const cliPath = process.env.MINDOS_CLI_PATH || resolve(process.cwd(), '..', 'bin', 'cli.js');
|
|
16
|
+
const nodeBin = process.env.MINDOS_NODE_BIN || process.execPath;
|
|
17
|
+
|
|
18
|
+
// Strip MINDOS_* env vars so the child reads fresh config
|
|
19
|
+
const childEnv = { ...process.env };
|
|
20
|
+
delete childEnv.MINDOS_WEB_PORT;
|
|
21
|
+
delete childEnv.MINDOS_MCP_PORT;
|
|
22
|
+
delete childEnv.MIND_ROOT;
|
|
23
|
+
delete childEnv.AUTH_TOKEN;
|
|
24
|
+
delete childEnv.WEB_PASSWORD;
|
|
25
|
+
|
|
26
|
+
const child = spawn(nodeBin, [cliPath, 'update'], {
|
|
27
|
+
detached: true,
|
|
28
|
+
stdio: 'ignore',
|
|
29
|
+
env: childEnv,
|
|
30
|
+
});
|
|
31
|
+
child.unref();
|
|
32
|
+
|
|
33
|
+
// Unlike /api/restart, we do NOT process.exit() here.
|
|
34
|
+
// `mindos update` will npm install first (30s+), then restart which
|
|
35
|
+
// kills this process. Exiting early would break the response.
|
|
36
|
+
return NextResponse.json({ ok: true });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { readSettings } from '@/lib/settings';
|
|
3
|
+
import ExploreContent from '@/components/explore/ExploreContent';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
export default function ExplorePage() {
|
|
8
|
+
const settings = readSettings();
|
|
9
|
+
if (settings.setupPending) redirect('/setup');
|
|
10
|
+
|
|
11
|
+
return <ExploreContent />;
|
|
12
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useRef, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
7
|
import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
|
|
7
8
|
import type { SyncStatus } from './settings/SyncTab';
|
|
8
9
|
import Logo from './Logo';
|
|
@@ -32,9 +33,11 @@ interface RailButtonProps {
|
|
|
32
33
|
buttonRef?: React.Ref<HTMLButtonElement>;
|
|
33
34
|
/** Optional overlay badge (e.g. status dot) rendered inside the button */
|
|
34
35
|
badge?: React.ReactNode;
|
|
36
|
+
/** Optional data-walkthrough attribute for interactive walkthrough targeting */
|
|
37
|
+
walkthroughId?: string;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
function RailButton({ icon, label, shortcut, active = false, expanded, onClick, buttonRef, badge }: RailButtonProps) {
|
|
40
|
+
function RailButton({ icon, label, shortcut, active = false, expanded, onClick, buttonRef, badge, walkthroughId }: RailButtonProps) {
|
|
38
41
|
return (
|
|
39
42
|
<button
|
|
40
43
|
ref={buttonRef}
|
|
@@ -42,6 +45,7 @@ function RailButton({ icon, label, shortcut, active = false, expanded, onClick,
|
|
|
42
45
|
aria-pressed={active}
|
|
43
46
|
aria-label={label}
|
|
44
47
|
title={expanded ? undefined : (shortcut ? `${label} (${shortcut})` : label)}
|
|
48
|
+
data-walkthrough={walkthroughId}
|
|
45
49
|
className={`
|
|
46
50
|
relative flex items-center ${expanded ? 'justify-start px-3 w-full' : 'justify-center w-10'} h-10 rounded-md transition-colors
|
|
47
51
|
${active
|
|
@@ -79,6 +83,7 @@ export default function ActivityBar({
|
|
|
79
83
|
}: ActivityBarProps) {
|
|
80
84
|
const lastClickRef = useRef(0);
|
|
81
85
|
const syncBtnRef = useRef<HTMLButtonElement>(null);
|
|
86
|
+
const { t } = useLocale();
|
|
82
87
|
|
|
83
88
|
/** Debounce rapid clicks (300ms) — shared across all Rail buttons */
|
|
84
89
|
const debounced = useCallback((fn: () => void) => {
|
|
@@ -109,6 +114,7 @@ export default function ActivityBar({
|
|
|
109
114
|
role="toolbar"
|
|
110
115
|
aria-label="Navigation"
|
|
111
116
|
aria-orientation="vertical"
|
|
117
|
+
data-walkthrough="activity-bar"
|
|
112
118
|
>
|
|
113
119
|
{/* Content wrapper — overflow-hidden prevents text flash during width transitions */}
|
|
114
120
|
<div className="flex flex-col h-full w-full overflow-hidden">
|
|
@@ -126,10 +132,10 @@ export default function ActivityBar({
|
|
|
126
132
|
|
|
127
133
|
{/* ── Middle: Core panel toggles ── */}
|
|
128
134
|
<div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
|
|
129
|
-
<RailButton icon={<FolderTree size={18} />} label=
|
|
130
|
-
<RailButton icon={<Search size={18} />} label=
|
|
131
|
-
<RailButton icon={<Blocks size={18} />} label=
|
|
132
|
-
<RailButton icon={<Bot size={18} />} label=
|
|
135
|
+
<RailButton icon={<FolderTree size={18} />} label={t.sidebar.files} active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} walkthroughId="files-panel" />
|
|
136
|
+
<RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} walkthroughId="search-button" />
|
|
137
|
+
<RailButton icon={<Blocks size={18} />} label={t.sidebar.plugins} active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
|
|
138
|
+
<RailButton icon={<Bot size={18} />} label={t.sidebar.agents} active={activePanel === 'agents'} expanded={expanded} onClick={() => toggle('agents')} />
|
|
133
139
|
</div>
|
|
134
140
|
|
|
135
141
|
{/* ── Spacer ── */}
|
|
@@ -140,14 +146,15 @@ export default function ActivityBar({
|
|
|
140
146
|
<div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
|
|
141
147
|
<RailButton
|
|
142
148
|
icon={<Settings size={18} />}
|
|
143
|
-
label=
|
|
149
|
+
label={t.sidebar.settingsTitle}
|
|
144
150
|
shortcut="⌘,"
|
|
145
151
|
expanded={expanded}
|
|
146
152
|
onClick={() => debounced(onSettingsClick)}
|
|
153
|
+
walkthroughId="settings-button"
|
|
147
154
|
/>
|
|
148
155
|
<RailButton
|
|
149
156
|
icon={<RefreshCw size={18} />}
|
|
150
|
-
label=
|
|
157
|
+
label={t.sidebar.syncLabel}
|
|
151
158
|
expanded={expanded}
|
|
152
159
|
buttonRef={syncBtnRef}
|
|
153
160
|
badge={syncBadge}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import { X, Sparkles, FolderOpen, MessageCircle, RefreshCw, Check, ChevronRight } from 'lucide-react';
|
|
5
|
+
import Link from 'next/link';
|
|
5
6
|
import { useLocale } from '@/lib/LocaleContext';
|
|
6
7
|
import { openAskModal } from '@/hooks/useAskModal';
|
|
8
|
+
import { walkthroughSteps } from './walkthrough/steps';
|
|
7
9
|
import type { GuideState } from '@/lib/settings';
|
|
8
10
|
|
|
9
11
|
const DIR_ICONS: Record<string, string> = {
|
|
@@ -47,20 +49,18 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
|
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
fetchGuideState();
|
|
49
51
|
|
|
50
|
-
//
|
|
51
|
-
const
|
|
52
|
-
if (params.get('welcome') === '1') {
|
|
52
|
+
// Listen for walkthrough-triggered first visit (WalkthroughProvider owns ?welcome=1 detection)
|
|
53
|
+
const handleFirstVisit = () => {
|
|
53
54
|
setIsFirstVisit(true);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
window.history.replaceState({}, '', url.pathname + (url.search || ''));
|
|
57
|
-
}
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener('mindos:first-visit', handleFirstVisit);
|
|
58
57
|
|
|
59
58
|
// Re-fetch when guide state is updated (e.g. after AskFab patches askedAI)
|
|
60
59
|
const handleGuideUpdate = () => fetchGuideState();
|
|
61
60
|
window.addEventListener('focus', handleGuideUpdate);
|
|
62
61
|
window.addEventListener('guide-state-updated', handleGuideUpdate);
|
|
63
62
|
return () => {
|
|
63
|
+
window.removeEventListener('mindos:first-visit', handleFirstVisit);
|
|
64
64
|
window.removeEventListener('focus', handleGuideUpdate);
|
|
65
65
|
window.removeEventListener('guide-state-updated', handleGuideUpdate);
|
|
66
66
|
};
|
|
@@ -147,6 +147,13 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
|
|
|
147
147
|
// Don't render if no active guide
|
|
148
148
|
if (!guideState) return null;
|
|
149
149
|
|
|
150
|
+
// Hide GuideCard while walkthrough is active
|
|
151
|
+
const walkthroughActive = guideState.walkthroughStep !== undefined
|
|
152
|
+
&& guideState.walkthroughStep >= 0
|
|
153
|
+
&& guideState.walkthroughStep < walkthroughSteps.length
|
|
154
|
+
&& !guideState.walkthroughDismissed;
|
|
155
|
+
if (walkthroughActive) return null;
|
|
156
|
+
|
|
150
157
|
const step1Done = guideState.step1Done;
|
|
151
158
|
const step2Done = guideState.askedAI;
|
|
152
159
|
const allDone = step1Done && step2Done;
|
|
@@ -164,6 +171,13 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
|
|
|
164
171
|
<span className="text-sm font-semibold flex-1" style={{ color: 'var(--foreground)' }}>
|
|
165
172
|
✨ {g.done.titleFinal}
|
|
166
173
|
</span>
|
|
174
|
+
<Link
|
|
175
|
+
href="/explore"
|
|
176
|
+
className="text-xs font-medium transition-colors hover:opacity-80"
|
|
177
|
+
style={{ color: 'var(--amber)' }}
|
|
178
|
+
>
|
|
179
|
+
{t.walkthrough.exploreCta}
|
|
180
|
+
</Link>
|
|
167
181
|
<button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
|
|
168
182
|
style={{ color: 'var(--muted-foreground)' }}>
|
|
169
183
|
<X size={14} />
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
-
import { FileText, Table, Clock, Sparkles,
|
|
5
|
-
import { useState, useEffect
|
|
4
|
+
import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass } from 'lucide-react';
|
|
5
|
+
import { useState, useEffect } from 'react';
|
|
6
6
|
import { useLocale } from '@/lib/LocaleContext';
|
|
7
7
|
import { encodePath, relativeTime } from '@/lib/utils';
|
|
8
8
|
import { getAllRenderers } from '@/lib/renderers/registry';
|
|
@@ -27,8 +27,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
27
27
|
const { t } = useLocale();
|
|
28
28
|
const [showAll, setShowAll] = useState(false);
|
|
29
29
|
const [suggestionIdx, setSuggestionIdx] = useState(0);
|
|
30
|
-
const [hintId, setHintId] = useState<string | null>(null);
|
|
31
|
-
const hintTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
|
32
30
|
|
|
33
31
|
const suggestions = t.ask?.suggestions ?? [
|
|
34
32
|
'Summarize this document',
|
|
@@ -44,15 +42,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
44
42
|
return () => clearInterval(interval);
|
|
45
43
|
}, [suggestions.length]);
|
|
46
44
|
|
|
47
|
-
// Cleanup hint timer on unmount
|
|
48
|
-
useEffect(() => () => { if (hintTimer.current) clearTimeout(hintTimer.current); }, []);
|
|
49
|
-
|
|
50
|
-
function showHint(id: string) {
|
|
51
|
-
if (hintTimer.current) clearTimeout(hintTimer.current);
|
|
52
|
-
setHintId(id);
|
|
53
|
-
hintTimer.current = setTimeout(() => setHintId(null), 3000);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
45
|
const existingSet = new Set(existingFiles ?? []);
|
|
57
46
|
|
|
58
47
|
// Empty knowledge base → show onboarding
|
|
@@ -62,9 +51,9 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
62
51
|
|
|
63
52
|
const formatTime = (mtime: number) => relativeTime(mtime, t.home.relativeTime);
|
|
64
53
|
|
|
65
|
-
// Only show renderers
|
|
66
|
-
// Opt-in renderers (like Graph) have no entryPath and are toggled from the view toolbar.
|
|
54
|
+
// Only show renderers that are available (have entryPath + file exists) as quick-access chips
|
|
67
55
|
const renderers = getAllRenderers().filter(r => r.entryPath);
|
|
56
|
+
const availablePlugins = renderers.filter(r => r.entryPath && existingSet.has(r.entryPath));
|
|
68
57
|
|
|
69
58
|
const lastFile = recent[0];
|
|
70
59
|
|
|
@@ -94,6 +83,7 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
94
83
|
<button
|
|
95
84
|
onClick={triggerAsk}
|
|
96
85
|
title="⌘/"
|
|
86
|
+
data-walkthrough="ask-button"
|
|
97
87
|
className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-150 hover:border-amber-500/50 hover:bg-amber-500/8"
|
|
98
88
|
style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
|
|
99
89
|
>
|
|
@@ -153,91 +143,35 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
153
143
|
<FilePlus size={14} />
|
|
154
144
|
<span>{t.home.newNote}</span>
|
|
155
145
|
</Link>
|
|
146
|
+
<Link
|
|
147
|
+
href="/explore"
|
|
148
|
+
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg text-sm font-medium transition-all duration-150 hover:translate-x-0.5"
|
|
149
|
+
style={{
|
|
150
|
+
color: 'var(--amber)',
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<Compass size={14} />
|
|
154
|
+
<span>{t.explore.title}</span>
|
|
155
|
+
</Link>
|
|
156
156
|
</div>
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 items-start">
|
|
174
|
-
{renderers.map((r) => {
|
|
175
|
-
const entryPath = r.entryPath ?? null;
|
|
176
|
-
const available = !entryPath || existingSet.has(entryPath);
|
|
177
|
-
|
|
178
|
-
if (!available) {
|
|
179
|
-
return (
|
|
180
|
-
<button
|
|
181
|
-
key={r.id}
|
|
182
|
-
onClick={() => showHint(r.id)}
|
|
183
|
-
className="group flex flex-col gap-1.5 px-3.5 py-3 rounded-lg border transition-all opacity-60 cursor-pointer hover:opacity-80 text-left"
|
|
184
|
-
style={{ borderColor: 'var(--border)' }}
|
|
185
|
-
>
|
|
186
|
-
<div className="flex items-center gap-2.5">
|
|
187
|
-
<span className="text-base leading-none shrink-0" suppressHydrationWarning>{r.icon}</span>
|
|
188
|
-
<span className="text-xs font-semibold truncate font-display" style={{ color: 'var(--foreground)' }}>
|
|
189
|
-
{r.name}
|
|
190
|
-
</span>
|
|
191
|
-
</div>
|
|
192
|
-
<p className="text-xs leading-relaxed line-clamp-2" style={{ color: 'var(--muted-foreground)' }}>
|
|
193
|
-
{r.description}
|
|
194
|
-
</p>
|
|
195
|
-
{hintId === r.id ? (
|
|
196
|
-
<p className="text-2xs animate-in" style={{ color: 'var(--amber)' }} role="status">
|
|
197
|
-
{(t.home.createToActivate ?? 'Create {file} to activate').replace('{file}', entryPath ?? '')}
|
|
198
|
-
</p>
|
|
199
|
-
) : (
|
|
200
|
-
<div className="flex flex-wrap gap-1">
|
|
201
|
-
{r.tags.slice(0, 3).map(tag => (
|
|
202
|
-
<span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
|
|
203
|
-
{tag}
|
|
204
|
-
</span>
|
|
205
|
-
))}
|
|
206
|
-
</div>
|
|
207
|
-
)}
|
|
208
|
-
</button>
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return (
|
|
213
|
-
<Link
|
|
214
|
-
key={r.id}
|
|
215
|
-
href={entryPath ? `/view/${encodePath(entryPath)}` : '#'}
|
|
216
|
-
className="group flex flex-col gap-1.5 px-3.5 py-3 rounded-lg border transition-all hover:border-amber-500/30 hover:bg-muted/50"
|
|
217
|
-
style={{ borderColor: 'var(--border)' }}
|
|
218
|
-
>
|
|
219
|
-
<div className="flex items-center gap-2.5">
|
|
220
|
-
<span className="text-base leading-none shrink-0" suppressHydrationWarning>{r.icon}</span>
|
|
221
|
-
<span className="text-xs font-semibold truncate font-display" style={{ color: 'var(--foreground)' }}>
|
|
222
|
-
{r.name}
|
|
223
|
-
</span>
|
|
224
|
-
</div>
|
|
225
|
-
<p className="text-xs leading-relaxed line-clamp-2" style={{ color: 'var(--muted-foreground)' }}>
|
|
226
|
-
{r.description}
|
|
227
|
-
</p>
|
|
228
|
-
<div className="flex flex-wrap gap-1">
|
|
229
|
-
{r.tags.slice(0, 3).map(tag => (
|
|
230
|
-
<span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
|
|
231
|
-
{tag}
|
|
232
|
-
</span>
|
|
233
|
-
))}
|
|
234
|
-
</div>
|
|
235
|
-
</Link>
|
|
236
|
-
);
|
|
237
|
-
})}
|
|
158
|
+
{/* Plugin quick-access chips — only show available plugins */}
|
|
159
|
+
{availablePlugins.length > 0 && (
|
|
160
|
+
<div className="flex flex-wrap gap-1.5 mt-3" style={{ paddingLeft: '1rem' }}>
|
|
161
|
+
{availablePlugins.map(r => (
|
|
162
|
+
<Link
|
|
163
|
+
key={r.id}
|
|
164
|
+
href={`/view/${encodePath(r.entryPath!)}`}
|
|
165
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs transition-all duration-100 hover:bg-muted/60"
|
|
166
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
167
|
+
>
|
|
168
|
+
<span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
|
|
169
|
+
<span>{r.name}</span>
|
|
170
|
+
</Link>
|
|
171
|
+
))}
|
|
238
172
|
</div>
|
|
239
|
-
|
|
240
|
-
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
241
175
|
|
|
242
176
|
{/* Recently modified — timeline feed */}
|
|
243
177
|
{recent.length > 0 && (() => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import { X } from 'lucide-react';
|
|
5
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
|
+
|
|
7
|
+
export default function KeyboardShortcuts() {
|
|
8
|
+
const [open, setOpen] = useState(false);
|
|
9
|
+
const { t } = useLocale();
|
|
10
|
+
const s = t.shortcutPanel;
|
|
11
|
+
|
|
12
|
+
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
|
|
13
|
+
const mod = isMac ? '⌘' : 'Ctrl';
|
|
14
|
+
|
|
15
|
+
const shortcuts = useMemo(() => [
|
|
16
|
+
{ keys: `${mod} K`, label: s.toggleSearch, section: s.navigation },
|
|
17
|
+
{ keys: `${mod} /`, label: s.toggleAskAI, section: s.navigation },
|
|
18
|
+
{ keys: `${mod} ,`, label: s.openSettings, section: s.navigation },
|
|
19
|
+
{ keys: `${mod} ?`, label: s.keyboardShortcuts, section: s.navigation },
|
|
20
|
+
{ keys: 'Esc', label: s.closePanel, section: s.panelsSection },
|
|
21
|
+
{ keys: `${mod} S`, label: s.saveFile, section: s.editor },
|
|
22
|
+
{ keys: `${mod} Z`, label: s.undo, section: s.editor },
|
|
23
|
+
{ keys: `${mod} Shift Z`, label: s.redo, section: s.editor },
|
|
24
|
+
], [mod, s]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const handler = (e: KeyboardEvent) => {
|
|
28
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '/') {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
setOpen(v => !v);
|
|
31
|
+
}
|
|
32
|
+
if (e.key === 'Escape' && open) {
|
|
33
|
+
setOpen(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener('keydown', handler);
|
|
37
|
+
return () => window.removeEventListener('keydown', handler);
|
|
38
|
+
}, [open]);
|
|
39
|
+
|
|
40
|
+
if (!open) return null;
|
|
41
|
+
|
|
42
|
+
const sections = [...new Set(shortcuts.map(s => s.section))];
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className="fixed inset-0 z-50 flex items-center justify-center modal-backdrop"
|
|
47
|
+
onClick={e => e.target === e.currentTarget && setOpen(false)}
|
|
48
|
+
>
|
|
49
|
+
<div
|
|
50
|
+
role="dialog"
|
|
51
|
+
aria-modal="true"
|
|
52
|
+
aria-label={s.title}
|
|
53
|
+
className="w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl overflow-hidden"
|
|
54
|
+
>
|
|
55
|
+
{/* Header */}
|
|
56
|
+
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
|
57
|
+
<span className="text-sm font-medium font-display text-foreground">{s.title}</span>
|
|
58
|
+
<button
|
|
59
|
+
onClick={() => setOpen(false)}
|
|
60
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
61
|
+
>
|
|
62
|
+
<X size={15} />
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Content */}
|
|
67
|
+
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
|
68
|
+
{sections.map(section => (
|
|
69
|
+
<div key={section}>
|
|
70
|
+
<h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">{section}</h3>
|
|
71
|
+
<div className="space-y-1">
|
|
72
|
+
{shortcuts.filter(s => s.section === section).map(s => (
|
|
73
|
+
<div key={s.keys} className="flex items-center justify-between py-1.5">
|
|
74
|
+
<span className="text-xs text-foreground">{s.label}</span>
|
|
75
|
+
<div className="flex items-center gap-1">
|
|
76
|
+
{s.keys.split(' ').map((key, i) => (
|
|
77
|
+
<kbd
|
|
78
|
+
key={i}
|
|
79
|
+
className="px-1.5 py-0.5 text-2xs rounded border border-border bg-muted text-muted-foreground font-mono min-w-[24px] text-center"
|
|
80
|
+
>
|
|
81
|
+
{key}
|
|
82
|
+
</kbd>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Footer */}
|
|
93
|
+
<div className="px-5 py-2.5 border-t border-border">
|
|
94
|
+
<p className="text-2xs text-muted-foreground/60">
|
|
95
|
+
Press <kbd className="px-1 py-0.5 text-2xs rounded border border-border bg-muted font-mono">{mod}</kbd>
|
|
96
|
+
<kbd className="px-1 py-0.5 text-2xs rounded border border-border bg-muted font-mono ml-0.5">?</kbd> {s.toggleHint}
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
package/app/components/Panel.tsx
CHANGED
|
@@ -8,6 +8,9 @@ import FileTree from './FileTree';
|
|
|
8
8
|
import SyncStatusBar from './SyncStatusBar';
|
|
9
9
|
import PanelHeader from './panels/PanelHeader';
|
|
10
10
|
import { useResizeDrag } from '@/hooks/useResizeDrag';
|
|
11
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
12
|
+
|
|
13
|
+
const noop = () => {};
|
|
11
14
|
|
|
12
15
|
/** Compute the maximum directory depth of a file tree */
|
|
13
16
|
function getMaxDepth(nodes: FileNode[], current = 0): number {
|
|
@@ -68,6 +71,8 @@ export default function Panel({
|
|
|
68
71
|
const defaultWidth = activePanel ? DEFAULT_PANEL_WIDTH[activePanel] : 280;
|
|
69
72
|
const width = maximized ? undefined : (panelWidth ?? defaultWidth);
|
|
70
73
|
|
|
74
|
+
const { t } = useLocale();
|
|
75
|
+
|
|
71
76
|
// File tree depth control: null = manual (no override), number = forced max open depth
|
|
72
77
|
const [maxOpenDepth, setMaxOpenDepth] = useState<number | null>(null);
|
|
73
78
|
const treeMaxDepth = useMemo(() => getMaxDepth(fileTree), [fileTree]);
|
|
@@ -79,8 +84,8 @@ export default function Panel({
|
|
|
79
84
|
maxWidthRatio: MAX_PANEL_WIDTH_RATIO,
|
|
80
85
|
direction: 'right',
|
|
81
86
|
disabled: maximized,
|
|
82
|
-
onResize: onWidthChange ??
|
|
83
|
-
onResizeEnd: onWidthCommit ??
|
|
87
|
+
onResize: onWidthChange ?? noop,
|
|
88
|
+
onResizeEnd: onWidthCommit ?? noop,
|
|
84
89
|
});
|
|
85
90
|
|
|
86
91
|
return (
|
|
@@ -97,7 +102,7 @@ export default function Panel({
|
|
|
97
102
|
>
|
|
98
103
|
{/* Files panel — always mounted to preserve tree expand/collapse state */}
|
|
99
104
|
<div className={`flex flex-col h-full ${activePanel === 'files' ? '' : 'hidden'}`}>
|
|
100
|
-
<PanelHeader title=
|
|
105
|
+
<PanelHeader title={t.sidebar.files}>
|
|
101
106
|
<div className="flex items-center gap-0.5">
|
|
102
107
|
<button
|
|
103
108
|
onClick={() => setMaxOpenDepth(prev => {
|
|
@@ -105,8 +110,8 @@ export default function Panel({
|
|
|
105
110
|
return Math.max(-1, current - 1);
|
|
106
111
|
})}
|
|
107
112
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
108
|
-
aria-label=
|
|
109
|
-
title=
|
|
113
|
+
aria-label={t.sidebar.collapseLevel}
|
|
114
|
+
title={t.sidebar.collapseLevel}
|
|
110
115
|
>
|
|
111
116
|
<ChevronsDownUp size={13} />
|
|
112
117
|
</button>
|
|
@@ -120,8 +125,8 @@ export default function Panel({
|
|
|
120
125
|
return next;
|
|
121
126
|
})}
|
|
122
127
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
123
|
-
aria-label=
|
|
124
|
-
title=
|
|
128
|
+
aria-label={t.sidebar.expandLevel}
|
|
129
|
+
title={t.sidebar.expandLevel}
|
|
125
130
|
>
|
|
126
131
|
<ChevronsUpDown size={13} />
|
|
127
132
|
</button>
|
|
@@ -17,10 +17,13 @@ import SyncPopover from './panels/SyncPopover';
|
|
|
17
17
|
import SearchModal from './SearchModal';
|
|
18
18
|
import AskModal from './AskModal';
|
|
19
19
|
import SettingsModal from './SettingsModal';
|
|
20
|
+
import KeyboardShortcuts from './KeyboardShortcuts';
|
|
20
21
|
import { MobileSyncDot, useSyncStatus } from './SyncStatusBar';
|
|
21
22
|
import { useAskModal } from '@/hooks/useAskModal';
|
|
22
23
|
import { FileNode } from '@/lib/types';
|
|
23
24
|
import { useLocale } from '@/lib/LocaleContext';
|
|
25
|
+
import { WalkthroughProvider } from './walkthrough';
|
|
26
|
+
import McpProvider from '@/hooks/useMcpData';
|
|
24
27
|
import type { Tab } from './settings/types';
|
|
25
28
|
|
|
26
29
|
interface SidebarLayoutProps {
|
|
@@ -177,6 +180,17 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
|
|
|
177
180
|
try { localStorage.setItem('rail-expanded', String(expanded)); } catch {}
|
|
178
181
|
}, []);
|
|
179
182
|
|
|
183
|
+
// Listen for cross-component "open settings" events (e.g. from UpdateBanner)
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const handler = (e: Event) => {
|
|
186
|
+
const tab = (e as CustomEvent).detail?.tab;
|
|
187
|
+
if (tab) setSettingsTab(tab);
|
|
188
|
+
setSettingsOpen(true);
|
|
189
|
+
};
|
|
190
|
+
window.addEventListener('mindos:open-settings', handler);
|
|
191
|
+
return () => window.removeEventListener('mindos:open-settings', handler);
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
180
194
|
// Bridge useAskModal store → right Ask panel or popup
|
|
181
195
|
useEffect(() => {
|
|
182
196
|
if (askModal.open) {
|
|
@@ -290,6 +304,8 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
|
|
|
290
304
|
const effectivePanelWidth = panelWidth ?? (activePanel ? PANEL_WIDTH[activePanel] : 280);
|
|
291
305
|
|
|
292
306
|
return (
|
|
307
|
+
<WalkthroughProvider>
|
|
308
|
+
<McpProvider>
|
|
293
309
|
<>
|
|
294
310
|
{/* Skip link */}
|
|
295
311
|
<a
|
|
@@ -335,7 +351,6 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
|
|
|
335
351
|
active={activePanel === 'agents'}
|
|
336
352
|
maximized={panelMaximized}
|
|
337
353
|
onMaximize={handlePanelMaximize}
|
|
338
|
-
onOpenSettings={openSettingsTab}
|
|
339
354
|
/>
|
|
340
355
|
</div>
|
|
341
356
|
</Panel>
|
|
@@ -368,6 +383,9 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
|
|
|
368
383
|
{/* ── Ask AI FAB (desktop only — toggles right panel or popup) ── */}
|
|
369
384
|
<AskFab onToggle={toggleAskPanel} askPanelOpen={askPanelOpen || desktopAskPopupOpen} />
|
|
370
385
|
|
|
386
|
+
{/* ── Keyboard Shortcuts (⌘?) ── */}
|
|
387
|
+
<KeyboardShortcuts />
|
|
388
|
+
|
|
371
389
|
{/* ── Settings Modal (desktop overlay — does not affect panel) ── */}
|
|
372
390
|
<SettingsModal
|
|
373
391
|
open={settingsOpen}
|
|
@@ -455,5 +473,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
|
|
|
455
473
|
}
|
|
456
474
|
`}</style>
|
|
457
475
|
</>
|
|
476
|
+
</McpProvider>
|
|
477
|
+
</WalkthroughProvider>
|
|
458
478
|
);
|
|
459
479
|
}
|
|
@@ -45,6 +45,13 @@ export default function UpdateBanner() {
|
|
|
45
45
|
setInfo(null);
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const handleOpenUpdate = () => {
|
|
49
|
+
window.dispatchEvent(new CustomEvent('mindos:open-settings', { detail: { tab: 'update' } }));
|
|
50
|
+
// Dismiss banner once user engages with Update tab
|
|
51
|
+
localStorage.setItem('mindos_update_dismissed', info.latest);
|
|
52
|
+
setInfo(null);
|
|
53
|
+
};
|
|
54
|
+
|
|
48
55
|
const updateT = t.updateBanner;
|
|
49
56
|
|
|
50
57
|
return (
|
|
@@ -58,32 +65,23 @@ export default function UpdateBanner() {
|
|
|
58
65
|
? updateT.newVersion(info.latest, info.current)
|
|
59
66
|
: `MindOS v${info.latest} available (current: v${info.current})`}
|
|
60
67
|
</span>
|
|
61
|
-
<
|
|
62
|
-
{
|
|
63
|
-
|
|
68
|
+
<button
|
|
69
|
+
onClick={handleOpenUpdate}
|
|
70
|
+
className="px-2 py-0.5 rounded-md text-xs font-medium text-white transition-colors hover:opacity-90"
|
|
71
|
+
style={{ background: 'var(--amber)' }}
|
|
72
|
+
>
|
|
73
|
+
{updateT?.updateNow ?? 'Update'}
|
|
74
|
+
</button>
|
|
75
|
+
<span className="text-muted-foreground hidden sm:inline">
|
|
64
76
|
{updateT?.orSee ? (
|
|
65
77
|
<>
|
|
66
|
-
{
|
|
67
|
-
<a
|
|
68
|
-
href="https://github.com/GeminiLight/mindos/releases"
|
|
69
|
-
target="_blank"
|
|
70
|
-
rel="noopener noreferrer"
|
|
71
|
-
className="underline hover:text-foreground transition-colors"
|
|
72
|
-
>
|
|
73
|
-
{updateT.releaseNotes}
|
|
74
|
-
</a>
|
|
78
|
+
{updateT.orSee}{' '}
|
|
79
|
+
<a href="https://github.com/GeminiLight/mindos/releases" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground transition-colors">{updateT.releaseNotes}</a>
|
|
75
80
|
</>
|
|
76
81
|
) : (
|
|
77
82
|
<>
|
|
78
|
-
|
|
79
|
-
<a
|
|
80
|
-
href="https://github.com/GeminiLight/mindos/releases"
|
|
81
|
-
target="_blank"
|
|
82
|
-
rel="noopener noreferrer"
|
|
83
|
-
className="underline hover:text-foreground transition-colors"
|
|
84
|
-
>
|
|
85
|
-
view release notes
|
|
86
|
-
</a>
|
|
83
|
+
or{' '}
|
|
84
|
+
<a href="https://github.com/GeminiLight/mindos/releases" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground transition-colors">release notes</a>
|
|
87
85
|
</>
|
|
88
86
|
)}
|
|
89
87
|
</span>
|