@geminilight/mindos 0.5.40 → 0.5.42

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/icon.svg CHANGED
@@ -1,35 +1,37 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
2
2
  <defs>
3
- <linearGradient id="g-human" x1="14" y1="16" x2="2" y2="16" gradientUnits="userSpaceOnUse">
3
+ <linearGradient id="grad-human" x1="35" y1="40" x2="5" y2="40" gradientUnits="userSpaceOnUse">
4
4
  <stop offset="0%" stop-color="#c8873a" stop-opacity="0.8"/>
5
5
  <stop offset="100%" stop-color="#c8873a" stop-opacity="0.3"/>
6
6
  </linearGradient>
7
- <linearGradient id="g-agent" x1="14" y1="16" x2="30" y2="16" gradientUnits="userSpaceOnUse">
7
+ <linearGradient id="grad-agent" x1="35" y1="40" x2="75" y2="40" gradientUnits="userSpaceOnUse">
8
8
  <stop offset="0%" stop-color="#c8873a" stop-opacity="0.8"/>
9
9
  <stop offset="100%" stop-color="#c8873a" stop-opacity="1"/>
10
10
  </linearGradient>
11
11
  </defs>
12
12
 
13
- <!-- Human Loop (Left) — dashed, lighter -->
14
- <path
15
- d="M14,16 C10,23 3.2,23 3.2,16 C3.2,9 10,9 14,16"
16
- stroke="url(#g-human)"
17
- stroke-width="1.8"
18
- stroke-dasharray="1.2 2.4"
19
- stroke-linecap="round"
20
- />
13
+ <g transform="translate(0, 20)">
14
+ <!-- Human Loop (Left) — dashed -->
15
+ <path
16
+ d="M35,20 C25,35 8,35 8,20 C8,5 25,5 35,20"
17
+ stroke="url(#grad-human)"
18
+ stroke-width="3"
19
+ stroke-dasharray="2 4"
20
+ stroke-linecap="round"
21
+ />
21
22
 
22
- <!-- Agent Loop (Right) — solid, bolder -->
23
- <path
24
- d="M14,16 C18,7 28.8,7 28.8,16 C28.8,25 18,25 14,16"
25
- stroke="url(#g-agent)"
26
- stroke-width="2.6"
27
- stroke-linecap="round"
28
- />
23
+ <!-- Agent Loop (Right) — solid -->
24
+ <path
25
+ d="M35,20 C45,2 75,2 75,20 C75,38 45,38 35,20"
26
+ stroke="url(#grad-agent)"
27
+ stroke-width="4.5"
28
+ stroke-linecap="round"
29
+ />
29
30
 
30
- <!-- Spark center -->
31
- <path
32
- d="M14,14.5 Q14,16 15.5,16 Q14,16 14,17.5 Q14,16 12.5,16 Q14,16 14,14.5 Z"
33
- fill="#FEF3C7"
34
- />
31
+ <!-- Spark center -->
32
+ <path
33
+ d="M35,17.5 Q35,20 37.5,20 Q35,20 35,22.5 Q35,20 32.5,20 Q35,20 35,17.5 Z"
34
+ fill="#FEF3C7"
35
+ />
36
+ </g>
35
37
  </svg>
@@ -8,6 +8,8 @@ import { LocaleProvider } from '@/lib/LocaleContext';
8
8
  import ErrorBoundary from '@/components/ErrorBoundary';
9
9
  import RegisterSW from './register-sw';
10
10
  import UpdateBanner from '@/components/UpdateBanner';
11
+ import { cookies } from 'next/headers';
12
+ import type { Locale } from '@/lib/i18n';
11
13
 
12
14
  const geistSans = Inter({
13
15
  variable: '--font-geist-sans',
@@ -57,7 +59,7 @@ export const viewport = {
57
59
  viewportFit: 'cover' as const,
58
60
  };
59
61
 
60
- export default function RootLayout({
62
+ export default async function RootLayout({
61
63
  children,
62
64
  }: Readonly<{
63
65
  children: React.ReactNode;
@@ -69,8 +71,12 @@ export default function RootLayout({
69
71
  console.error('[RootLayout] Failed to load file tree:', err);
70
72
  }
71
73
 
74
+ // Read locale from cookie (set by pre-hydration script) so SSR matches client
75
+ const cookieStore = await cookies();
76
+ const ssrLocale: Locale = cookieStore.get('locale')?.value === 'zh' ? 'zh' : 'en';
77
+
72
78
  return (
73
- <html lang="en" suppressHydrationWarning>
79
+ <html lang={ssrLocale} suppressHydrationWarning>
74
80
  <head>
75
81
  <meta name="theme-color" content="#c8871e" />
76
82
  {/* Patch Node.removeChild/insertBefore to swallow errors caused by browser
@@ -84,7 +90,7 @@ export default function RootLayout({
84
90
  {/* Apply user appearance settings before first paint, preventing flash */}
85
91
  <script
86
92
  dangerouslySetInnerHTML={{
87
- __html: `(function(){try{var s=localStorage.getItem('theme');var dark=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',dark);var cw=localStorage.getItem('content-width');if(cw)document.documentElement.style.setProperty('--content-width-override',cw);var pf=localStorage.getItem('prose-font');var fm={lora:'"Lora", Georgia, serif','ibm-plex-sans':'"IBM Plex Sans", sans-serif',geist:'var(--font-geist-sans), sans-serif','ibm-plex-mono':'"IBM Plex Mono", monospace'};if(pf&&fm[pf])document.documentElement.style.setProperty('--prose-font-override',fm[pf]);}catch(e){}})();`,
93
+ __html: `(function(){try{var s=localStorage.getItem('theme');var dark=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',dark);var cw=localStorage.getItem('content-width');if(cw)document.documentElement.style.setProperty('--content-width-override',cw);var pf=localStorage.getItem('prose-font');var fm={lora:'"Lora", Georgia, serif','ibm-plex-sans':'"IBM Plex Sans", sans-serif',geist:'var(--font-geist-sans), sans-serif','ibm-plex-mono':'"IBM Plex Mono", monospace'};if(pf&&fm[pf])document.documentElement.style.setProperty('--prose-font-override',fm[pf]);var loc=localStorage.getItem('locale')||'en';document.documentElement.lang=loc==='zh'?'zh':'en';document.cookie='locale='+loc+';path=/;max-age=31536000;SameSite=Lax'}catch(e){}})();`,
88
94
  }}
89
95
  />
90
96
  </head>
@@ -92,7 +98,7 @@ export default function RootLayout({
92
98
  className={`${geistSans.variable} ${geistMono.variable} ${ibmPlexMono.variable} ${ibmPlexSans.variable} ${lora.variable} antialiased bg-background text-foreground`}
93
99
  suppressHydrationWarning
94
100
  >
95
- <LocaleProvider>
101
+ <LocaleProvider ssrLocale={ssrLocale}>
96
102
  <UpdateBanner />
97
103
  <TooltipProvider delay={300}>
98
104
  <ErrorBoundary>
package/app/app/page.tsx CHANGED
@@ -35,6 +35,21 @@ function extractDescription(spacePath: string): string {
35
35
  return '';
36
36
  }
37
37
 
38
+ /** Recursively collect all directory paths from the file tree */
39
+ function collectDirPaths(nodes: FileNode[], prefix = ''): string[] {
40
+ const result: string[] = [];
41
+ for (const n of nodes) {
42
+ if (n.type === 'directory' && !n.name.startsWith('.')) {
43
+ const path = prefix ? `${prefix}/${n.name}` : n.name;
44
+ result.push(path);
45
+ if (n.children) {
46
+ result.push(...collectDirPaths(n.children, path));
47
+ }
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+
38
53
  function getTopLevelDirs(): SpaceInfo[] {
39
54
  try {
40
55
  const tree = getFileTree();
@@ -76,5 +91,9 @@ export default function HomePage() {
76
91
 
77
92
  const spaces = getTopLevelDirs();
78
93
 
79
- return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} />;
94
+ // Collect all directory paths for hierarchical space creation
95
+ let dirPaths: string[] = [];
96
+ try { dirPaths = collectDirPaths(getFileTree()); } catch { /* ignore */ }
97
+
98
+ return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} dirPaths={dirPaths} />;
80
99
  }
@@ -15,6 +15,8 @@ import TableOfContents from '@/components/TableOfContents';
15
15
  import FindInPage from '@/components/FindInPage';
16
16
  import { resolveRenderer, isRendererEnabled } from '@/lib/renderers/registry';
17
17
  import { encodePath } from '@/lib/utils';
18
+ import { useLocale } from '@/lib/LocaleContext';
19
+ import DirPicker from '@/components/DirPicker';
18
20
  import '@/lib/renderers/index'; // registers all renderers
19
21
 
20
22
  interface ViewPageClientProps {
@@ -40,6 +42,7 @@ export default function ViewPageClient({
40
42
  draftDirectories = [],
41
43
  createDraftAction,
42
44
  }: ViewPageClientProps) {
45
+ const { t } = useLocale();
43
46
  const hydrated = useSyncExternalStore(
44
47
  () => () => {},
45
48
  () => true,
@@ -304,27 +307,25 @@ export default function ViewPageClient({
304
307
  {editing ? (
305
308
  <div className="content-width xl:mr-[220px]">
306
309
  {isDraft && showSaveAs && (
307
- <div className="mb-3 rounded-lg border border-border bg-card p-3 flex flex-col md:flex-row md:items-end gap-2">
308
- <div className="flex-1 min-w-[180px]">
309
- <label className="text-xs text-muted-foreground">Directory</label>
310
- <select
311
- value={saveDir}
312
- onChange={(e) => setSaveDir(e.target.value)}
313
- className="mt-1 w-full px-2 py-1.5 text-sm bg-background border border-border rounded text-foreground"
314
- >
315
- <option value="">/</option>
316
- {draftDirectories.map((dir) => (
317
- <option key={dir} value={dir}>{dir}</option>
318
- ))}
319
- </select>
310
+ <div className="mb-3 rounded-lg border border-border bg-card p-3 flex flex-col gap-2">
311
+ <div>
312
+ <label className="text-xs text-muted-foreground">{t.view?.saveDirectory ?? 'Directory'}</label>
313
+ <div className="mt-1">
314
+ <DirPicker
315
+ dirPaths={draftDirectories}
316
+ value={saveDir}
317
+ onChange={setSaveDir}
318
+ rootLabel={t.home?.rootLevel ?? 'Root'}
319
+ />
320
+ </div>
320
321
  </div>
321
- <div className="flex-1 min-w-[200px]">
322
- <label className="text-xs text-muted-foreground">File name</label>
322
+ <div>
323
+ <label className="text-xs text-muted-foreground">{t.view?.saveFileName ?? 'File name'}</label>
323
324
  <input
324
325
  value={saveName}
325
326
  onChange={(e) => setSaveName(e.target.value)}
326
327
  onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmDraftSave(); }}
327
- className="mt-1 w-full px-2 py-1.5 text-sm bg-background border border-border rounded text-foreground"
328
+ className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
328
329
  placeholder="Untitled.md"
329
330
  />
330
331
  </div>
@@ -1,14 +1,14 @@
1
1
  'use client';
2
2
 
3
- import { useRef, useCallback } from 'react';
3
+ import { useRef, useCallback, useState, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
- import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
5
+ import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, Compass, ChevronLeft, ChevronRight } from 'lucide-react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
8
8
  import type { SyncStatus } from './settings/SyncTab';
9
9
  import Logo from './Logo';
10
10
 
11
- export type PanelId = 'files' | 'search' | 'plugins' | 'agents';
11
+ export type PanelId = 'files' | 'search' | 'plugins' | 'agents' | 'discover';
12
12
 
13
13
  export const RAIL_WIDTH_COLLAPSED = 48;
14
14
  export const RAIL_WIDTH_EXPANDED = 180;
@@ -85,6 +85,28 @@ export default function ActivityBar({
85
85
  const syncBtnRef = useRef<HTMLButtonElement>(null);
86
86
  const { t } = useLocale();
87
87
 
88
+ // Update available badge — check localStorage for persisted state
89
+ const [hasUpdate, setHasUpdate] = useState(() => {
90
+ if (typeof window === 'undefined') return false;
91
+ const dismissed = localStorage.getItem('mindos_update_dismissed');
92
+ const latest = localStorage.getItem('mindos_update_latest');
93
+ return !!latest && latest !== dismissed;
94
+ });
95
+ useEffect(() => {
96
+ const onAvail = (e: Event) => {
97
+ const latest = (e as CustomEvent).detail?.latest;
98
+ if (latest) localStorage.setItem('mindos_update_latest', latest);
99
+ setHasUpdate(true);
100
+ };
101
+ const onDismiss = () => setHasUpdate(false);
102
+ window.addEventListener('mindos:update-available', onAvail);
103
+ window.addEventListener('mindos:update-dismissed', onDismiss);
104
+ return () => {
105
+ window.removeEventListener('mindos:update-available', onAvail);
106
+ window.removeEventListener('mindos:update-dismissed', onDismiss);
107
+ };
108
+ }, []);
109
+
88
110
  /** Debounce rapid clicks (300ms) — shared across all Rail buttons */
89
111
  const debounced = useCallback((fn: () => void) => {
90
112
  const now = Date.now();
@@ -136,6 +158,7 @@ export default function ActivityBar({
136
158
  <RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} walkthroughId="search-button" />
137
159
  <RailButton icon={<Blocks size={18} />} label={t.sidebar.plugins} active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
138
160
  <RailButton icon={<Bot size={18} />} label={t.sidebar.agents} active={activePanel === 'agents'} expanded={expanded} onClick={() => toggle('agents')} />
161
+ <RailButton icon={<Compass size={18} />} label={t.sidebar.discover} active={activePanel === 'discover'} expanded={expanded} onClick={() => toggle('discover')} />
139
162
  </div>
140
163
 
141
164
  {/* ── Spacer ── */}
@@ -151,6 +174,9 @@ export default function ActivityBar({
151
174
  expanded={expanded}
152
175
  onClick={() => debounced(onSettingsClick)}
153
176
  walkthroughId="settings-button"
177
+ badge={hasUpdate ? (
178
+ <span className={`absolute ${expanded ? 'left-[26px] top-1.5' : 'top-1.5 right-1.5'} w-2 h-2 rounded-full bg-error`} />
179
+ ) : undefined}
154
180
  />
155
181
  <RailButton
156
182
  icon={<RefreshCw size={18} />}
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { Folder, Loader2, X } from 'lucide-react';
6
+ import { useRouter } from 'next/navigation';
7
+ import { useLocale } from '@/lib/LocaleContext';
8
+ import { encodePath } from '@/lib/utils';
9
+ import { createSpaceAction } from '@/lib/actions';
10
+ import DirPicker from './DirPicker';
11
+
12
+ /* ── Create Space Modal ── */
13
+
14
+ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof useLocale>['t']; dirPaths: string[] }) {
15
+ const router = useRouter();
16
+ const [open, setOpen] = useState(false);
17
+ const [name, setName] = useState('');
18
+ const [description, setDescription] = useState('');
19
+ const [parent, setParent] = useState('');
20
+ const [loading, setLoading] = useState(false);
21
+ const [error, setError] = useState('');
22
+ const [nameHint, setNameHint] = useState('');
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+
25
+ useEffect(() => {
26
+ const handler = () => {
27
+ setOpen(true);
28
+ setError('');
29
+ setNameHint('');
30
+ setTimeout(() => inputRef.current?.focus(), 80);
31
+ };
32
+ window.addEventListener('mindos:create-space', handler);
33
+ return () => window.removeEventListener('mindos:create-space', handler);
34
+ }, []);
35
+
36
+ const close = useCallback(() => {
37
+ setOpen(false);
38
+ setName('');
39
+ setDescription('');
40
+ setParent('');
41
+ setError('');
42
+ setNameHint('');
43
+ }, []);
44
+
45
+ const validateName = useCallback((val: string) => {
46
+ if (val.includes('/') || val.includes('\\')) {
47
+ setNameHint(t.home.spaceNameNoSlash ?? 'Name cannot contain / or \\');
48
+ return false;
49
+ }
50
+ setNameHint('');
51
+ return true;
52
+ }, [t]);
53
+
54
+ const fullPathPreview = useMemo(() => {
55
+ const trimmed = name.trim();
56
+ if (!trimmed) return '';
57
+ return (parent ? parent + '/' : '') + trimmed + '/';
58
+ }, [name, parent]);
59
+
60
+ const handleCreate = useCallback(async () => {
61
+ if (!name.trim() || loading) return;
62
+ if (!validateName(name)) return;
63
+ setLoading(true);
64
+ setError('');
65
+ const trimmed = name.trim();
66
+ const result = await createSpaceAction(trimmed, description, parent);
67
+ setLoading(false);
68
+ if (result.success) {
69
+ const createdPath = result.path ?? trimmed;
70
+ close();
71
+ router.refresh();
72
+ router.push(`/view/${encodePath(createdPath + '/')}`);
73
+ } else {
74
+ const msg = result.error ?? '';
75
+ if (msg.includes('already exists')) {
76
+ setError(t.home.spaceExists ?? 'A space with this name already exists');
77
+ } else {
78
+ setError(t.home.spaceCreateFailed ?? 'Failed to create space');
79
+ }
80
+ }
81
+ }, [name, description, parent, loading, close, router, t, validateName]);
82
+
83
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
84
+ if (e.key === 'Escape') close();
85
+ if (e.key === 'Enter' && !e.shiftKey && e.target instanceof HTMLInputElement) { e.preventDefault(); handleCreate(); }
86
+ }, [close, handleCreate]);
87
+
88
+ if (!open) return null;
89
+
90
+ return createPortal(
91
+ <div className="fixed inset-0 z-50 flex items-center justify-center" onKeyDown={handleKeyDown}>
92
+ {/* Backdrop */}
93
+ <div className="absolute inset-0 bg-black/40 dark:bg-black/60" onClick={close} />
94
+ {/* Dialog */}
95
+ <div
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-label={t.home.newSpace}
99
+ className="relative w-full max-w-md mx-4 rounded-2xl border border-border bg-card shadow-xl"
100
+ >
101
+ {/* Header */}
102
+ <div className="flex items-center justify-between px-5 pt-5 pb-3">
103
+ <h3 className="text-sm font-semibold font-display text-foreground">{t.home.newSpace}</h3>
104
+ <button onClick={close} className="p-1 rounded-md text-muted-foreground hover:bg-muted transition-colors">
105
+ <X size={14} />
106
+ </button>
107
+ </div>
108
+ {/* Body */}
109
+ <div className="px-5 pb-5 flex flex-col gap-3">
110
+ {/* Location — hierarchical browser */}
111
+ <div className="space-y-1">
112
+ <label className="text-xs font-medium text-muted-foreground">{t.home.spaceLocation ?? 'Location'}</label>
113
+ <DirPicker
114
+ dirPaths={dirPaths}
115
+ value={parent}
116
+ onChange={setParent}
117
+ rootLabel={t.home.rootLevel ?? 'Root'}
118
+ />
119
+ </div>
120
+ {/* Name */}
121
+ <div className="space-y-1">
122
+ <label className="text-xs font-medium text-muted-foreground">{t.home.spaceName}</label>
123
+ <input
124
+ ref={inputRef}
125
+ type="text"
126
+ value={name}
127
+ onChange={e => { setName(e.target.value); setError(''); validateName(e.target.value); }}
128
+ placeholder="e.g. 📝 Notes"
129
+ maxLength={80}
130
+ aria-invalid={!!nameHint}
131
+ aria-describedby={nameHint ? 'space-name-hint' : undefined}
132
+ className={`w-full px-3 py-2 text-sm rounded-lg border bg-background outline-none transition-colors ${
133
+ nameHint ? 'border-error focus:border-error' : 'border-border focus-visible:ring-1 focus-visible:ring-ring'
134
+ }`}
135
+ />
136
+ {nameHint && <span id="space-name-hint" className="text-xs text-error">{nameHint}</span>}
137
+ </div>
138
+ {/* Description */}
139
+ <div className="space-y-1">
140
+ <label className="text-xs font-medium text-muted-foreground">
141
+ {t.home.spaceDescription} <span className="opacity-50">({t.home.optional ?? 'optional'})</span>
142
+ </label>
143
+ <input
144
+ type="text"
145
+ value={description}
146
+ onChange={e => setDescription(e.target.value)}
147
+ placeholder={t.home.spaceDescPlaceholder ?? 'Describe the purpose of this space'}
148
+ maxLength={200}
149
+ className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors"
150
+ />
151
+ </div>
152
+ {/* Path preview */}
153
+ {fullPathPreview && (
154
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono px-1">
155
+ <Folder size={12} className="shrink-0 text-[var(--amber)]" />
156
+ <span className="truncate">{fullPathPreview}</span>
157
+ </div>
158
+ )}
159
+ {error && <span role="alert" className="text-xs text-error px-1">{error}</span>}
160
+ {/* Actions */}
161
+ <div className="flex items-center justify-end gap-2 pt-1">
162
+ <button
163
+ onClick={close}
164
+ className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
165
+ >
166
+ {t.home.cancelCreate}
167
+ </button>
168
+ <button
169
+ onClick={handleCreate}
170
+ disabled={!name.trim() || loading || !!nameHint}
171
+ className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
172
+ >
173
+ {loading && <Loader2 size={14} className="animate-spin" />}
174
+ {t.home.createSpace}
175
+ </button>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>,
180
+ document.body,
181
+ );
182
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo } from 'react';
4
+ import { Folder, ChevronDown, ChevronRight } from 'lucide-react';
5
+
6
+ interface DirPickerProps {
7
+ /** Flat list of all directory paths (e.g. ['Notes', 'Notes/Daily', 'Projects']) */
8
+ dirPaths: string[];
9
+ /** Currently selected path ('' = root) */
10
+ value: string;
11
+ /** Called when user selects a directory */
12
+ onChange: (path: string) => void;
13
+ /** Label for root level */
14
+ rootLabel?: string;
15
+ }
16
+
17
+ /**
18
+ * Hierarchical directory picker — collapsed by default as a single-line button,
19
+ * expands into a mini file browser with breadcrumb navigation.
20
+ */
21
+ export default function DirPicker({ dirPaths, value, onChange, rootLabel = 'Root' }: DirPickerProps) {
22
+ const [expanded, setExpanded] = useState(false);
23
+ const [browsing, setBrowsing] = useState(value);
24
+
25
+ useEffect(() => { setBrowsing(value); }, [value]);
26
+
27
+ const children = useMemo(() => {
28
+ const prefix = browsing ? browsing + '/' : '';
29
+ return dirPaths
30
+ .filter(p => {
31
+ if (!p.startsWith(prefix)) return false;
32
+ const rest = p.slice(prefix.length);
33
+ return rest.length > 0 && !rest.includes('/');
34
+ })
35
+ .sort();
36
+ }, [dirPaths, browsing]);
37
+
38
+ const segments = useMemo(() => browsing ? browsing.split('/') : [], [browsing]);
39
+
40
+ const navigateTo = (idx: number) => {
41
+ if (idx < 0) { setBrowsing(''); onChange(''); }
42
+ else { const p = segments.slice(0, idx + 1).join('/'); setBrowsing(p); onChange(p); }
43
+ };
44
+
45
+ const drillInto = (childPath: string) => {
46
+ setBrowsing(childPath);
47
+ onChange(childPath);
48
+ };
49
+
50
+ const displayLabel = value
51
+ ? value.split('/').map(s => s.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || s).join(' / ')
52
+ : '/ ' + rootLabel;
53
+
54
+ if (!expanded) {
55
+ return (
56
+ <button
57
+ type="button"
58
+ onClick={() => setExpanded(true)}
59
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-border bg-background text-foreground hover:border-amber-500/40 transition-colors text-left"
60
+ >
61
+ <Folder size={14} className="shrink-0 text-[var(--amber)]" />
62
+ <span className="flex-1 truncate">{displayLabel}</span>
63
+ <ChevronDown size={14} className="shrink-0 text-muted-foreground" />
64
+ </button>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div className="rounded-lg border border-[var(--amber)] bg-background overflow-hidden max-h-[200px] flex flex-col">
70
+ {/* Breadcrumb */}
71
+ <div className="flex items-center gap-0.5 px-3 py-1.5 bg-muted/30 border-b border-border overflow-x-auto text-xs shrink-0">
72
+ <button
73
+ type="button"
74
+ onClick={() => navigateTo(-1)}
75
+ className={`shrink-0 px-1.5 py-0.5 rounded transition-colors ${
76
+ browsing === '' ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
77
+ }`}
78
+ >
79
+ / {rootLabel}
80
+ </button>
81
+ {segments.map((seg, i) => (
82
+ <span key={i} className="flex items-center gap-0.5 shrink-0">
83
+ <ChevronRight size={10} className="text-muted-foreground/50" />
84
+ <button
85
+ type="button"
86
+ onClick={() => navigateTo(i)}
87
+ className={`px-1.5 py-0.5 rounded transition-colors truncate max-w-[100px] ${
88
+ i === segments.length - 1 ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
89
+ }`}
90
+ >
91
+ {seg}
92
+ </button>
93
+ </span>
94
+ ))}
95
+ </div>
96
+ {/* Child directories */}
97
+ {children.length > 0 ? (
98
+ <div className="flex-1 min-h-0 overflow-y-auto">
99
+ {children.map(childPath => {
100
+ const childName = childPath.split('/').pop() || childPath;
101
+ const hasChildren = dirPaths.some(p => p.startsWith(childPath + '/'));
102
+ return (
103
+ <button
104
+ key={childPath}
105
+ type="button"
106
+ onClick={() => drillInto(childPath)}
107
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-foreground hover:bg-muted/60 transition-colors"
108
+ >
109
+ <Folder size={12} className="shrink-0 text-[var(--amber)]" />
110
+ <span className="flex-1 text-left truncate">{childName}</span>
111
+ {hasChildren && <ChevronRight size={11} className="shrink-0 text-muted-foreground/40" />}
112
+ </button>
113
+ );
114
+ })}
115
+ </div>
116
+ ) : (
117
+ <div className="px-3 py-2 text-xs text-muted-foreground/50 text-center">—</div>
118
+ )}
119
+ {/* Collapse */}
120
+ <button
121
+ type="button"
122
+ onClick={() => setExpanded(false)}
123
+ className="w-full py-1.5 text-xs font-medium text-[var(--amber)] border-t border-border hover:bg-muted/30 transition-colors shrink-0"
124
+ >
125
+
126
+ </button>
127
+ </div>
128
+ );
129
+ }