@geminilight/mindos 0.6.15 → 0.6.16
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/globals.css +49 -2
- package/app/app/page.tsx +1 -19
- package/app/components/CreateSpaceModal.tsx +3 -3
- package/app/components/CustomSelect.tsx +228 -0
- package/app/components/DirPicker.tsx +110 -63
- package/app/components/FileTree.tsx +54 -32
- package/app/components/HomeContent.tsx +69 -13
- package/app/components/ImportModal.tsx +92 -39
- package/app/components/Panel.tsx +87 -21
- package/app/components/SidebarLayout.tsx +18 -5
- package/app/components/changes/ChangesContentPage.tsx +34 -23
- package/app/components/renderers/csv/ConfigPanel.tsx +10 -7
- package/app/components/settings/KnowledgeTab.tsx +38 -12
- package/app/components/settings/McpAgentInstall.tsx +14 -30
- package/app/components/settings/McpSkillsSection.tsx +43 -27
- package/app/components/settings/McpTab.tsx +30 -37
- package/app/components/settings/UninstallTab.tsx +1 -1
- package/app/components/setup/StepAgents.tsx +1 -1
- package/app/lib/core/create-space.ts +12 -0
- package/app/lib/i18n-en.ts +19 -2
- package/app/lib/i18n-zh.ts +21 -4
- package/package.json +1 -1
package/app/app/globals.css
CHANGED
|
@@ -78,7 +78,7 @@ body {
|
|
|
78
78
|
--amber-text: #9a6a2b;
|
|
79
79
|
--amber-dim: rgba(200, 135, 58, 0.18);
|
|
80
80
|
--amber-subtle: rgba(200, 135, 30, 0.08);
|
|
81
|
-
--amber-foreground: #
|
|
81
|
+
--amber-foreground: #ffffff;
|
|
82
82
|
--success: #7aad80;
|
|
83
83
|
--error: #c85050;
|
|
84
84
|
--sidebar: #ede9e1;
|
|
@@ -114,7 +114,7 @@ body {
|
|
|
114
114
|
--amber-text: #e0a85e;
|
|
115
115
|
--amber-dim: rgba(212, 149, 74, 0.20);
|
|
116
116
|
--amber-subtle: rgba(212, 149, 74, 0.10);
|
|
117
|
-
--amber-foreground: #
|
|
117
|
+
--amber-foreground: #ffffff;
|
|
118
118
|
--success: #7aad80;
|
|
119
119
|
--error: #c85050;
|
|
120
120
|
--sidebar: #1c1a17;
|
|
@@ -434,6 +434,53 @@ a:focus-visible,
|
|
|
434
434
|
outline: none;
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/* ── Styled form controls ──────────────────────────────────── */
|
|
438
|
+
|
|
439
|
+
input[type="checkbox"].form-check {
|
|
440
|
+
appearance: none;
|
|
441
|
+
width: 14px; height: 14px;
|
|
442
|
+
flex-shrink: 0;
|
|
443
|
+
border-radius: 3px;
|
|
444
|
+
border: 1px solid var(--border);
|
|
445
|
+
background: var(--background);
|
|
446
|
+
cursor: pointer;
|
|
447
|
+
transition: background-color 0.15s, border-color 0.15s;
|
|
448
|
+
background-repeat: no-repeat;
|
|
449
|
+
background-position: center;
|
|
450
|
+
background-size: 100% 100%;
|
|
451
|
+
}
|
|
452
|
+
input[type="checkbox"].form-check:checked {
|
|
453
|
+
background-color: var(--amber);
|
|
454
|
+
border-color: var(--amber);
|
|
455
|
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3E%3C/svg%3E");
|
|
456
|
+
}
|
|
457
|
+
input[type="checkbox"].form-check:focus-visible {
|
|
458
|
+
outline: none;
|
|
459
|
+
box-shadow: 0 0 0 2px var(--ring);
|
|
460
|
+
}
|
|
461
|
+
input[type="checkbox"].form-check:disabled {
|
|
462
|
+
opacity: 0.5;
|
|
463
|
+
cursor: not-allowed;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
input[type="radio"].form-radio {
|
|
467
|
+
appearance: none;
|
|
468
|
+
width: 14px; height: 14px;
|
|
469
|
+
flex-shrink: 0;
|
|
470
|
+
border-radius: 9999px;
|
|
471
|
+
border: 1px solid var(--border);
|
|
472
|
+
background: var(--background);
|
|
473
|
+
cursor: pointer;
|
|
474
|
+
transition: border-color 0.15s;
|
|
475
|
+
}
|
|
476
|
+
input[type="radio"].form-radio:checked {
|
|
477
|
+
border: 4px solid var(--amber);
|
|
478
|
+
}
|
|
479
|
+
input[type="radio"].form-radio:focus-visible {
|
|
480
|
+
outline: none;
|
|
481
|
+
box-shadow: 0 0 0 2px var(--ring);
|
|
482
|
+
}
|
|
483
|
+
|
|
437
484
|
/* macOS Electron: traffic-light safe zone. Actual CSS is injected by Electron
|
|
438
485
|
main process via webContents.insertCSS(); this is a no-op fallback. */
|
|
439
486
|
.electron-mac-titlebar-pad { display: none; }
|
package/app/app/page.tsx
CHANGED
|
@@ -34,20 +34,6 @@ function extractDescription(spacePath: string): string {
|
|
|
34
34
|
return '';
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Recursively collect all directory paths from the file tree */
|
|
38
|
-
function collectDirPaths(nodes: FileNode[], prefix = ''): string[] {
|
|
39
|
-
const result: string[] = [];
|
|
40
|
-
for (const n of nodes) {
|
|
41
|
-
if (n.type === 'directory' && !n.name.startsWith('.')) {
|
|
42
|
-
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
43
|
-
result.push(path);
|
|
44
|
-
if (n.children) {
|
|
45
|
-
result.push(...collectDirPaths(n.children, path));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return result;
|
|
50
|
-
}
|
|
51
37
|
|
|
52
38
|
function getTopLevelDirs(): SpaceInfo[] {
|
|
53
39
|
try {
|
|
@@ -90,9 +76,5 @@ export default function HomePage() {
|
|
|
90
76
|
|
|
91
77
|
const spaces = getTopLevelDirs();
|
|
92
78
|
|
|
93
|
-
|
|
94
|
-
let dirPaths: string[] = [];
|
|
95
|
-
try { dirPaths = collectDirPaths(getFileTree()); } catch { /* ignore */ }
|
|
96
|
-
|
|
97
|
-
return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} dirPaths={dirPaths} />;
|
|
79
|
+
return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} />;
|
|
98
80
|
}
|
|
@@ -168,7 +168,7 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
|
|
|
168
168
|
>
|
|
169
169
|
{/* Header */}
|
|
170
170
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
|
171
|
-
<h3 className="text-
|
|
171
|
+
<h3 className="text-base font-semibold font-display text-foreground">{h.newSpace}</h3>
|
|
172
172
|
<button onClick={close} className="p-1 rounded-md text-muted-foreground hover:bg-muted transition-colors">
|
|
173
173
|
<X size={14} />
|
|
174
174
|
</button>
|
|
@@ -214,7 +214,7 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
|
|
|
214
214
|
onChange={e => setDescription(e.target.value)}
|
|
215
215
|
placeholder={h.spaceDescPlaceholder ?? 'Describe the purpose of this space'}
|
|
216
216
|
maxLength={200}
|
|
217
|
-
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"
|
|
217
|
+
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors"
|
|
218
218
|
/>
|
|
219
219
|
</div>
|
|
220
220
|
{/* AI initialization toggle */}
|
|
@@ -268,7 +268,7 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
|
|
|
268
268
|
<button
|
|
269
269
|
onClick={handleCreate}
|
|
270
270
|
disabled={!name.trim() || loading || !!nameHint}
|
|
271
|
-
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-
|
|
271
|
+
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
272
272
|
>
|
|
273
273
|
{loading && <Loader2 size={14} className="animate-spin" />}
|
|
274
274
|
{h.createSpace}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import { ChevronDown, Check } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export interface SelectOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
suffix?: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SelectOptionGroup {
|
|
14
|
+
label: string;
|
|
15
|
+
options: SelectOption[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SelectItem = SelectOption | SelectOptionGroup;
|
|
19
|
+
|
|
20
|
+
function isGroup(item: SelectItem): item is SelectOptionGroup {
|
|
21
|
+
return 'options' in item;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function flatOptions(items: SelectItem[]): SelectOption[] {
|
|
25
|
+
const result: SelectOption[] = [];
|
|
26
|
+
for (const item of items) {
|
|
27
|
+
if (isGroup(item)) result.push(...item.options);
|
|
28
|
+
else result.push(item);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CustomSelectProps {
|
|
34
|
+
value: string;
|
|
35
|
+
onChange: (value: string) => void;
|
|
36
|
+
options: SelectItem[];
|
|
37
|
+
className?: string;
|
|
38
|
+
/** 'sm' for inline/compact, 'md' (default) for form fields */
|
|
39
|
+
size?: 'sm' | 'md';
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function CustomSelect({
|
|
44
|
+
value,
|
|
45
|
+
onChange,
|
|
46
|
+
options,
|
|
47
|
+
className = '',
|
|
48
|
+
size = 'md',
|
|
49
|
+
placeholder,
|
|
50
|
+
}: CustomSelectProps) {
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const [highlightIdx, setHighlightIdx] = useState(-1);
|
|
53
|
+
const [flipUp, setFlipUp] = useState(false);
|
|
54
|
+
const btnRef = useRef<HTMLButtonElement>(null);
|
|
55
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
|
|
57
|
+
const allOptions = useMemo(() => flatOptions(options), [options]);
|
|
58
|
+
|
|
59
|
+
const selectedLabel = useMemo(() => {
|
|
60
|
+
return allOptions.find(o => o.value === value) ?? null;
|
|
61
|
+
}, [allOptions, value]);
|
|
62
|
+
|
|
63
|
+
const close = useCallback(() => {
|
|
64
|
+
setOpen(false);
|
|
65
|
+
setHighlightIdx(-1);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const select = useCallback((val: string) => {
|
|
69
|
+
onChange(val);
|
|
70
|
+
close();
|
|
71
|
+
}, [onChange, close]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return;
|
|
75
|
+
const handleClick = (e: MouseEvent) => {
|
|
76
|
+
if (
|
|
77
|
+
btnRef.current && !btnRef.current.contains(e.target as Node) &&
|
|
78
|
+
listRef.current && !listRef.current.contains(e.target as Node)
|
|
79
|
+
) {
|
|
80
|
+
close();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
document.addEventListener('mousedown', handleClick);
|
|
84
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
85
|
+
}, [open, close]);
|
|
86
|
+
|
|
87
|
+
// Keyboard navigation
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!open) return;
|
|
90
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
91
|
+
switch (e.key) {
|
|
92
|
+
case 'Escape':
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
close();
|
|
95
|
+
btnRef.current?.focus();
|
|
96
|
+
break;
|
|
97
|
+
case 'ArrowDown':
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
setHighlightIdx(prev => Math.min(prev + 1, allOptions.length - 1));
|
|
100
|
+
break;
|
|
101
|
+
case 'ArrowUp':
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setHighlightIdx(prev => Math.max(prev - 1, 0));
|
|
104
|
+
break;
|
|
105
|
+
case 'Enter':
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (highlightIdx >= 0 && highlightIdx < allOptions.length) {
|
|
108
|
+
select(allOptions[highlightIdx].value);
|
|
109
|
+
btnRef.current?.focus();
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
document.addEventListener('keydown', handleKey);
|
|
115
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
116
|
+
}, [open, close, highlightIdx, allOptions, select]);
|
|
117
|
+
|
|
118
|
+
// Scroll highlighted option into view
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!open || !listRef.current || highlightIdx < 0) return;
|
|
121
|
+
const el = listRef.current.querySelector(`[data-idx="${highlightIdx}"]`);
|
|
122
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
123
|
+
}, [open, highlightIdx]);
|
|
124
|
+
|
|
125
|
+
// Initialize highlight + flip direction when opening
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (open) {
|
|
128
|
+
const idx = allOptions.findIndex(o => o.value === value);
|
|
129
|
+
setHighlightIdx(idx >= 0 ? idx : 0);
|
|
130
|
+
if (btnRef.current) {
|
|
131
|
+
const rect = btnRef.current.getBoundingClientRect();
|
|
132
|
+
const maxH = size === 'sm' ? 200 : 260;
|
|
133
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
134
|
+
const spaceAbove = rect.top;
|
|
135
|
+
setFlipUp(spaceBelow < maxH + 8 && spaceAbove > spaceBelow);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}, [open, allOptions, value, size]);
|
|
139
|
+
|
|
140
|
+
const isSm = size === 'sm';
|
|
141
|
+
|
|
142
|
+
const triggerCls = isSm
|
|
143
|
+
? `inline-flex items-center gap-1 appearance-none rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors text-2xs px-1.5 py-0.5 pr-5 ${className}`
|
|
144
|
+
: `w-full flex items-center gap-2 appearance-none rounded-lg border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors text-sm px-3 py-2 pr-8 ${className}`;
|
|
145
|
+
|
|
146
|
+
const chevronCls = isSm
|
|
147
|
+
? 'absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none'
|
|
148
|
+
: 'absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none';
|
|
149
|
+
|
|
150
|
+
const listCls = isSm
|
|
151
|
+
? `absolute z-50 min-w-full max-h-[200px] overflow-y-auto rounded-md border border-border bg-card shadow-lg py-0.5 ${flipUp ? 'bottom-full mb-1' : 'top-full mt-1'}`
|
|
152
|
+
: `absolute z-50 min-w-full max-h-[260px] overflow-y-auto rounded-lg border border-border bg-card shadow-lg py-1 ${flipUp ? 'bottom-full mb-1' : 'top-full mt-1'}`;
|
|
153
|
+
|
|
154
|
+
const itemBaseCls = isSm
|
|
155
|
+
? 'w-full flex items-center gap-1.5 px-2 py-1 text-2xs text-left transition-colors cursor-pointer'
|
|
156
|
+
: 'w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer';
|
|
157
|
+
|
|
158
|
+
// Track flat index across groups for keyboard highlight
|
|
159
|
+
let flatIdx = -1;
|
|
160
|
+
|
|
161
|
+
function renderOption(opt: SelectOption) {
|
|
162
|
+
flatIdx++;
|
|
163
|
+
const idx = flatIdx;
|
|
164
|
+
const isSelected = opt.value === value;
|
|
165
|
+
const isHighlighted = idx === highlightIdx;
|
|
166
|
+
return (
|
|
167
|
+
<button
|
|
168
|
+
key={opt.value}
|
|
169
|
+
type="button"
|
|
170
|
+
role="option"
|
|
171
|
+
aria-selected={isSelected}
|
|
172
|
+
data-idx={idx}
|
|
173
|
+
onClick={() => select(opt.value)}
|
|
174
|
+
onMouseEnter={() => setHighlightIdx(idx)}
|
|
175
|
+
className={`${itemBaseCls} ${
|
|
176
|
+
isHighlighted ? 'bg-muted text-foreground' : 'text-foreground'
|
|
177
|
+
}`}
|
|
178
|
+
>
|
|
179
|
+
{opt.icon}
|
|
180
|
+
<span className="flex-1 truncate">{opt.label}</span>
|
|
181
|
+
{opt.suffix}
|
|
182
|
+
{isSelected && <Check size={isSm ? 10 : 12} className="shrink-0 text-[var(--amber)]" />}
|
|
183
|
+
</button>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="relative">
|
|
189
|
+
<button
|
|
190
|
+
ref={btnRef}
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => setOpen(v => !v)}
|
|
193
|
+
aria-haspopup="listbox"
|
|
194
|
+
aria-expanded={open}
|
|
195
|
+
className={triggerCls}
|
|
196
|
+
>
|
|
197
|
+
{selectedLabel?.icon}
|
|
198
|
+
<span className="flex-1 truncate text-left">
|
|
199
|
+
{selectedLabel?.label ?? placeholder ?? '—'}
|
|
200
|
+
</span>
|
|
201
|
+
{selectedLabel?.suffix}
|
|
202
|
+
<ChevronDown
|
|
203
|
+
size={isSm ? 12 : 14}
|
|
204
|
+
className={`${chevronCls} transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
|
205
|
+
/>
|
|
206
|
+
</button>
|
|
207
|
+
|
|
208
|
+
{open && (
|
|
209
|
+
<div ref={listRef} className={listCls} role="listbox">
|
|
210
|
+
{options.map((item, idx) => {
|
|
211
|
+
if (isGroup(item)) {
|
|
212
|
+
return (
|
|
213
|
+
<div key={item.label}>
|
|
214
|
+
{idx > 0 && <div className="my-0.5 border-t border-border/50" />}
|
|
215
|
+
<div className={`py-1 text-2xs font-medium text-muted-foreground uppercase tracking-wider ${isSm ? 'px-2' : 'px-3'}`}>
|
|
216
|
+
{item.label}
|
|
217
|
+
</div>
|
|
218
|
+
{item.options.map(renderOption)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return renderOption(item);
|
|
223
|
+
})}
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
-
import { Folder, ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
|
+
import { Folder, ChevronDown, ChevronRight, Check } from 'lucide-react';
|
|
5
5
|
import { stripEmoji } from '@/lib/utils';
|
|
6
6
|
|
|
7
7
|
interface DirPickerProps {
|
|
@@ -15,16 +15,51 @@ interface DirPickerProps {
|
|
|
15
15
|
rootLabel?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const PANEL_MAX_H = 200;
|
|
19
|
+
|
|
18
20
|
/**
|
|
19
|
-
* Hierarchical directory picker —
|
|
20
|
-
*
|
|
21
|
+
* Hierarchical directory picker — always renders as a single-line trigger button.
|
|
22
|
+
* When expanded, the tree browser floats as an overlay (absolute) so it never
|
|
23
|
+
* pushes sibling content down.
|
|
21
24
|
*/
|
|
22
25
|
export default function DirPicker({ dirPaths, value, onChange, rootLabel = 'Root' }: DirPickerProps) {
|
|
23
26
|
const [expanded, setExpanded] = useState(false);
|
|
24
27
|
const [browsing, setBrowsing] = useState(value);
|
|
28
|
+
const [flipUp, setFlipUp] = useState(false);
|
|
29
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const btnRef = useRef<HTMLButtonElement>(null);
|
|
25
31
|
|
|
26
32
|
useEffect(() => { setBrowsing(value); }, [value]);
|
|
27
33
|
|
|
34
|
+
// Decide flip direction when opening
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!expanded || !btnRef.current) return;
|
|
37
|
+
const rect = btnRef.current.getBoundingClientRect();
|
|
38
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
39
|
+
const spaceAbove = rect.top;
|
|
40
|
+
setFlipUp(spaceBelow < PANEL_MAX_H + 8 && spaceAbove > spaceBelow);
|
|
41
|
+
}, [expanded]);
|
|
42
|
+
|
|
43
|
+
const collapse = useCallback(() => setExpanded(false), []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!expanded) return;
|
|
47
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
48
|
+
if (e.key === 'Escape') { e.preventDefault(); collapse(); }
|
|
49
|
+
};
|
|
50
|
+
const handleClick = (e: MouseEvent) => {
|
|
51
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
52
|
+
collapse();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener('keydown', handleKey);
|
|
56
|
+
document.addEventListener('mousedown', handleClick);
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener('keydown', handleKey);
|
|
59
|
+
document.removeEventListener('mousedown', handleClick);
|
|
60
|
+
};
|
|
61
|
+
}, [expanded, collapse]);
|
|
62
|
+
|
|
28
63
|
const children = useMemo(() => {
|
|
29
64
|
const prefix = browsing ? browsing + '/' : '';
|
|
30
65
|
return dirPaths
|
|
@@ -52,79 +87,91 @@ export default function DirPicker({ dirPaths, value, onChange, rootLabel = 'Root
|
|
|
52
87
|
? value.split('/').map(s => stripEmoji(s)).join(' / ')
|
|
53
88
|
: '/ ' + rootLabel;
|
|
54
89
|
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
return (
|
|
91
|
+
<div ref={containerRef} className="relative">
|
|
92
|
+
{/* Trigger — always in document flow */}
|
|
57
93
|
<button
|
|
94
|
+
ref={btnRef}
|
|
58
95
|
type="button"
|
|
59
|
-
onClick={() => setExpanded(
|
|
60
|
-
|
|
96
|
+
onClick={() => setExpanded(v => !v)}
|
|
97
|
+
aria-haspopup="true"
|
|
98
|
+
aria-expanded={expanded}
|
|
99
|
+
className={`w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg border bg-background text-foreground transition-colors text-left focus-visible:ring-1 focus-visible:ring-ring outline-none ${
|
|
100
|
+
expanded ? 'border-[var(--amber)]' : 'border-border hover:border-[var(--amber)]/40'
|
|
101
|
+
}`}
|
|
61
102
|
>
|
|
62
103
|
<Folder size={14} className="shrink-0 text-[var(--amber)]" />
|
|
63
104
|
<span className="flex-1 truncate">{displayLabel}</span>
|
|
64
|
-
<ChevronDown
|
|
105
|
+
<ChevronDown
|
|
106
|
+
size={14}
|
|
107
|
+
className={`shrink-0 text-muted-foreground transition-transform duration-150 ${expanded ? 'rotate-180' : ''}`}
|
|
108
|
+
/>
|
|
65
109
|
</button>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
110
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
className={`shrink-0 px-1.5 py-0.5 rounded transition-colors ${
|
|
77
|
-
browsing === '' ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
78
|
-
}`}
|
|
79
|
-
>
|
|
80
|
-
/ {rootLabel}
|
|
81
|
-
</button>
|
|
82
|
-
{segments.map((seg, i) => (
|
|
83
|
-
<span key={i} className="flex items-center gap-0.5 shrink-0">
|
|
84
|
-
<ChevronRight size={10} className="text-muted-foreground/50" />
|
|
111
|
+
{/* Floating panel — absolute, never pushes content */}
|
|
112
|
+
{expanded && (
|
|
113
|
+
<div className={`absolute z-50 left-0 right-0 rounded-lg border border-[var(--amber)] bg-card shadow-lg overflow-hidden max-h-[200px] flex flex-col ${
|
|
114
|
+
flipUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
|
115
|
+
}`}>
|
|
116
|
+
{/* Breadcrumb */}
|
|
117
|
+
<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">
|
|
85
118
|
<button
|
|
86
119
|
type="button"
|
|
87
|
-
onClick={() => navigateTo(
|
|
88
|
-
className={`px-1.5 py-0.5 rounded transition-colors
|
|
89
|
-
|
|
120
|
+
onClick={() => navigateTo(-1)}
|
|
121
|
+
className={`shrink-0 px-1.5 py-0.5 rounded transition-colors ${
|
|
122
|
+
browsing === '' ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
90
123
|
}`}
|
|
91
124
|
>
|
|
92
|
-
{
|
|
125
|
+
/ {rootLabel}
|
|
93
126
|
</button>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
{segments.map((seg, i) => (
|
|
128
|
+
<span key={i} className="flex items-center gap-0.5 shrink-0">
|
|
129
|
+
<ChevronRight size={10} className="text-muted-foreground/50" />
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => navigateTo(i)}
|
|
133
|
+
className={`px-1.5 py-0.5 rounded transition-colors truncate max-w-[100px] ${
|
|
134
|
+
i === segments.length - 1 ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
135
|
+
}`}
|
|
136
|
+
>
|
|
137
|
+
{seg}
|
|
138
|
+
</button>
|
|
139
|
+
</span>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
{/* Child directories */}
|
|
143
|
+
{children.length > 0 ? (
|
|
144
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
145
|
+
{children.map(childPath => {
|
|
146
|
+
const childName = childPath.split('/').pop() || childPath;
|
|
147
|
+
const hasChildren = dirPaths.some(p => p.startsWith(childPath + '/'));
|
|
148
|
+
return (
|
|
149
|
+
<button
|
|
150
|
+
key={childPath}
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => drillInto(childPath)}
|
|
153
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-foreground hover:bg-muted/60 transition-colors"
|
|
154
|
+
>
|
|
155
|
+
<Folder size={12} className="shrink-0 text-[var(--amber)]" />
|
|
156
|
+
<span className="flex-1 text-left truncate">{childName}</span>
|
|
157
|
+
{hasChildren && <ChevronRight size={11} className="shrink-0 text-muted-foreground/40" />}
|
|
158
|
+
</button>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="px-3 py-2 text-xs text-muted-foreground/50 text-center">—</div>
|
|
164
|
+
)}
|
|
165
|
+
{/* Confirm & collapse */}
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={collapse}
|
|
169
|
+
className="w-full py-1.5 flex items-center justify-center gap-1 text-xs font-medium text-[var(--amber)] border-t border-border hover:bg-muted/30 transition-colors shrink-0"
|
|
170
|
+
>
|
|
171
|
+
<Check size={12} />
|
|
172
|
+
</button>
|
|
116
173
|
</div>
|
|
117
|
-
) : (
|
|
118
|
-
<div className="px-3 py-2 text-xs text-muted-foreground/50 text-center">—</div>
|
|
119
174
|
)}
|
|
120
|
-
{/* Collapse */}
|
|
121
|
-
<button
|
|
122
|
-
type="button"
|
|
123
|
-
onClick={() => setExpanded(false)}
|
|
124
|
-
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"
|
|
125
|
-
>
|
|
126
|
-
✓
|
|
127
|
-
</button>
|
|
128
175
|
</div>
|
|
129
176
|
);
|
|
130
177
|
}
|