cantip 0.1.0
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/LICENSE +21 -0
- package/README.md +61 -0
- package/app/components/CanvasMount.tsx +62 -0
- package/app/components/CodeWrapToggle.tsx +78 -0
- package/app/components/FindOnPage.tsx +224 -0
- package/app/components/MobileBottomBar.tsx +93 -0
- package/app/components/MobileProjectsPanel.tsx +113 -0
- package/app/components/PageFloatingMenu.tsx +224 -0
- package/app/components/ProjectSwitcher.tsx +124 -0
- package/app/components/Search.tsx +930 -0
- package/app/components/ShortcutsHelp.tsx +113 -0
- package/app/components/Sidebar.tsx +1049 -0
- package/app/components/TabBar.tsx +227 -0
- package/app/components/Toc.tsx +129 -0
- package/app/components/TopBar.tsx +74 -0
- package/app/components/theme-toggle.tsx +71 -0
- package/app/components/ui/button.tsx +56 -0
- package/app/components/ui/card.tsx +55 -0
- package/app/components/ui/dropdown-menu.tsx +156 -0
- package/app/components/ui/input.tsx +21 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +155 -0
- package/app/generated/site.ts +19 -0
- package/app/generated/slots.ts +10 -0
- package/app/generated/theme.generated.css +60 -0
- package/app/lib/config/config.server.ts +50 -0
- package/app/lib/config/defaults.ts +120 -0
- package/app/lib/config/load.ts +82 -0
- package/app/lib/config/schema.ts +131 -0
- package/app/lib/config/site.ts +43 -0
- package/app/lib/content.server.ts +105 -0
- package/app/lib/projects.ts +86 -0
- package/app/lib/sidebar.server.ts +113 -0
- package/app/lib/site.ts +27 -0
- package/app/lib/slots.tsx +33 -0
- package/app/lib/tabs.tsx +128 -0
- package/app/lib/useKeyboardShortcuts.ts +149 -0
- package/app/lib/utils.ts +17 -0
- package/app/root.tsx +171 -0
- package/app/routes/$.tsx +158 -0
- package/app/routes/_index.tsx +60 -0
- package/app/styles/app.css +461 -0
- package/app/styles/obsidian.css +83 -0
- package/app/styles/tailwind.css +227 -0
- package/cli.js +119 -0
- package/components.json +21 -0
- package/dist/config.mjs +87 -0
- package/dist/generate-content.mjs +1665 -0
- package/package.json +112 -0
- package/scripts/build-search-index.ts +129 -0
- package/scripts/canonical.ts +34 -0
- package/scripts/canvas-to-md.ts +73 -0
- package/scripts/compile.ts +242 -0
- package/scripts/emit-config.ts +163 -0
- package/scripts/generate-content.ts +197 -0
- package/scripts/obsidian/files.ts +222 -0
- package/scripts/obsidian/fs.ts +34 -0
- package/scripts/obsidian/generate.ts +36 -0
- package/scripts/obsidian/html.ts +17 -0
- package/scripts/obsidian/logger.ts +10 -0
- package/scripts/obsidian/markdown.ts +56 -0
- package/scripts/obsidian/obsidian.ts +229 -0
- package/scripts/obsidian/path.ts +60 -0
- package/scripts/obsidian/rehype.ts +60 -0
- package/scripts/obsidian/remark.ts +712 -0
- package/scripts/obsidian/types.ts +31 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useTree } from '@headless-tree/react'
|
|
3
|
+
import {
|
|
4
|
+
syncDataLoaderFeature,
|
|
5
|
+
hotkeysCoreFeature,
|
|
6
|
+
expandAllFeature,
|
|
7
|
+
selectionFeature,
|
|
8
|
+
} from '@headless-tree/core'
|
|
9
|
+
import { Link, useNavigate } from '@remix-run/react'
|
|
10
|
+
import {
|
|
11
|
+
ChevronRight,
|
|
12
|
+
ChevronsDownUp,
|
|
13
|
+
File as FileIcon,
|
|
14
|
+
Folder,
|
|
15
|
+
Image as ImageIcon,
|
|
16
|
+
LayoutDashboard,
|
|
17
|
+
Locate,
|
|
18
|
+
MoreVertical,
|
|
19
|
+
Search,
|
|
20
|
+
} from 'lucide-react'
|
|
21
|
+
import { createPortal } from 'react-dom'
|
|
22
|
+
|
|
23
|
+
import type { FlatSidebarItem, FlatSidebarMap, SidebarNodeType } from '~/lib/sidebar.server'
|
|
24
|
+
import { Button } from '~/components/ui/button'
|
|
25
|
+
import { useTabs } from '~/lib/tabs'
|
|
26
|
+
import { t } from '~/lib/site'
|
|
27
|
+
import { useKeyboardShortcuts, type Shortcut } from '~/lib/useKeyboardShortcuts'
|
|
28
|
+
import { cn } from '~/lib/utils'
|
|
29
|
+
|
|
30
|
+
const WIDTH_STORAGE_KEY = 'sidebar-width'
|
|
31
|
+
const DEFAULT_WIDTH = 280
|
|
32
|
+
// Practically unlimited: a tiny floor so the drag handle is always grabbable, and
|
|
33
|
+
// a ceiling tied to the viewport (set at drag time) so the content column can't be
|
|
34
|
+
// pushed off-screen. These bounds exist only to keep the layout recoverable.
|
|
35
|
+
const MIN_WIDTH = 48
|
|
36
|
+
const MAX_VIEWPORT_GAP = 64 // px kept free on the right so content never vanishes
|
|
37
|
+
const TOC_WIDTH = 250 // width of the right "На этой странице" column (xl+ only)
|
|
38
|
+
const CONTENT_MIN = 640 // hide the TOC once the content column would be narrower
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inline script injected into <head> (before paint) so the persisted sidebar
|
|
42
|
+
* width — and the derived TOC visibility — are applied to <html> before first
|
|
43
|
+
* render, avoiding a layout shift on load. Sets `--sidebar-width`, `--toc-width`
|
|
44
|
+
* (collapses the TOC column to 0 when content would be too narrow), and toggles a
|
|
45
|
+
* `toc-collapsed` class the Toc component keys off to hide itself. Falls back to
|
|
46
|
+
* the default width when nothing is stored. Logic mirrors `applyLayout` below —
|
|
47
|
+
* keep the two in sync.
|
|
48
|
+
*/
|
|
49
|
+
export const sidebarWidthInitScript = `(function(){try{var w=parseInt(localStorage.getItem('${WIDTH_STORAGE_KEY}'),10);if(!w||isNaN(w))w=${DEFAULT_WIDTH};var vw=window.innerWidth;var max=Math.max(${MIN_WIDTH},vw-${MAX_VIEWPORT_GAP});w=Math.min(max,Math.max(${MIN_WIDTH},w));var d=document.documentElement;d.style.setProperty('--sidebar-width',w+'px');var roomForToc=vw-w-${TOC_WIDTH}>=${CONTENT_MIN};d.style.setProperty('--toc-width',roomForToc?'${TOC_WIDTH}px':'0px');d.classList.toggle('toc-collapsed',!roomForToc);}catch(e){var d=document.documentElement;d.style.setProperty('--sidebar-width','${DEFAULT_WIDTH}px');d.style.setProperty('--toc-width','${TOC_WIDTH}px');}})();`
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clamp a requested sidebar width to the viewport and apply it, plus the derived
|
|
53
|
+
* TOC visibility, to <html>. Returns the clamped width (for persistence). Mirrors
|
|
54
|
+
* `sidebarWidthInitScript` — keep the two in sync. Returns null off the client.
|
|
55
|
+
*/
|
|
56
|
+
function applyLayout(requested: number): number | null {
|
|
57
|
+
if (typeof window === 'undefined') return null
|
|
58
|
+
const vw = window.innerWidth
|
|
59
|
+
const max = Math.max(MIN_WIDTH, vw - MAX_VIEWPORT_GAP)
|
|
60
|
+
const w = Math.min(max, Math.max(MIN_WIDTH, requested))
|
|
61
|
+
const d = document.documentElement
|
|
62
|
+
d.style.setProperty('--sidebar-width', `${w}px`)
|
|
63
|
+
const roomForToc = vw - w - TOC_WIDTH >= CONTENT_MIN
|
|
64
|
+
d.style.setProperty('--toc-width', roomForToc ? `${TOC_WIDTH}px` : '0px')
|
|
65
|
+
d.classList.toggle('toc-collapsed', !roomForToc)
|
|
66
|
+
return w
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const icons: Record<SidebarNodeType, JSX.Element> = {
|
|
70
|
+
directory: <Folder className="size-4" />,
|
|
71
|
+
file: <FileIcon className="size-4" />,
|
|
72
|
+
canvas: <LayoutDashboard className="size-4" />,
|
|
73
|
+
image: <ImageIcon className="size-4" />,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const iconColor: Record<SidebarNodeType, string> = {
|
|
77
|
+
directory: 'text-sidebar-foreground/70',
|
|
78
|
+
file: 'text-muted-foreground',
|
|
79
|
+
canvas: 'text-amber-500',
|
|
80
|
+
image: 'text-emerald-500',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Icon component per node type (for places that need the component, not an element). */
|
|
84
|
+
const iconComponent: Record<SidebarNodeType, typeof FileIcon> = {
|
|
85
|
+
directory: Folder,
|
|
86
|
+
file: FileIcon,
|
|
87
|
+
canvas: LayoutDashboard,
|
|
88
|
+
image: ImageIcon,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Props {
|
|
92
|
+
data: FlatSidebarMap
|
|
93
|
+
currentPath: string
|
|
94
|
+
open?: boolean
|
|
95
|
+
className?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rowBase =
|
|
99
|
+
'group flex items-center gap-1 rounded-md py-1.5 pr-2 text-sm cursor-pointer select-none transition-colors hover:bg-sidebar-accent'
|
|
100
|
+
|
|
101
|
+
/** Static, non-interactive tree for SSR / pre-hydration (links work without JS).
|
|
102
|
+
* Only renders children of folders in `expanded`, so the initial HTML matches
|
|
103
|
+
* the interactive tree's collapsed state — no expand→collapse flash on hydrate,
|
|
104
|
+
* and a far smaller DOM to send and parse. */
|
|
105
|
+
function FallbackTree({
|
|
106
|
+
data,
|
|
107
|
+
currentPath,
|
|
108
|
+
expanded,
|
|
109
|
+
}: {
|
|
110
|
+
data: FlatSidebarMap
|
|
111
|
+
currentPath: string
|
|
112
|
+
expanded: Set<string>
|
|
113
|
+
}) {
|
|
114
|
+
const cur = normPath(currentPath)
|
|
115
|
+
const renderNodes = (ids: string[], level: number): JSX.Element => (
|
|
116
|
+
<ul className="list-none m-0 p-0">
|
|
117
|
+
{ids.map((id) => {
|
|
118
|
+
const item = data[id]
|
|
119
|
+
if (!item) return null
|
|
120
|
+
const isActive = item.href ? normPath(item.href) === cur : false
|
|
121
|
+
const isFolder = item.children.length > 0
|
|
122
|
+
const isOpen = isFolder && expanded.has(id)
|
|
123
|
+
return (
|
|
124
|
+
<li key={id}>
|
|
125
|
+
<div
|
|
126
|
+
title={item.name}
|
|
127
|
+
className={cn(rowBase, isActive && 'bg-sidebar-accent')}
|
|
128
|
+
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
|
129
|
+
>
|
|
130
|
+
{isFolder ? (
|
|
131
|
+
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
132
|
+
<ChevronRight className={cn('size-3.5 transition-transform', isOpen && 'rotate-90')} />
|
|
133
|
+
</span>
|
|
134
|
+
) : (
|
|
135
|
+
<span className="size-4 shrink-0" />
|
|
136
|
+
)}
|
|
137
|
+
<span className={cn('flex size-4 shrink-0 items-center justify-center', iconColor[item.type])}>
|
|
138
|
+
{icons[item.type] ?? icons.file}
|
|
139
|
+
</span>
|
|
140
|
+
{item.href ? (
|
|
141
|
+
<Link
|
|
142
|
+
to={item.href}
|
|
143
|
+
className={cn(
|
|
144
|
+
'flex-1 min-w-0 truncate no-underline',
|
|
145
|
+
isActive ? 'text-foreground font-medium' : 'text-sidebar-foreground/80',
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{item.name}
|
|
149
|
+
</Link>
|
|
150
|
+
) : (
|
|
151
|
+
<span
|
|
152
|
+
className={cn(
|
|
153
|
+
'flex-1 min-w-0 truncate',
|
|
154
|
+
level === 0 ? 'font-semibold text-foreground' : 'text-sidebar-foreground/80',
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
{item.name}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{isOpen && renderNodes(item.children, level + 1)}
|
|
162
|
+
</li>
|
|
163
|
+
)
|
|
164
|
+
})}
|
|
165
|
+
</ul>
|
|
166
|
+
)
|
|
167
|
+
return <div className="outline-none">{renderNodes(data['root']?.children ?? [], 0)}</div>
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Normalise a path for active-item comparison: decoded, no trailing slash. */
|
|
171
|
+
function normPath(p?: string): string {
|
|
172
|
+
if (!p) return ''
|
|
173
|
+
return decodeURIComponent(p).replace(/\/+$/, '')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function highlightParts(text: string, query: string): JSX.Element {
|
|
177
|
+
if (!query) return <>{text}</>
|
|
178
|
+
const lower = text.toLowerCase()
|
|
179
|
+
const idx = lower.indexOf(query.toLowerCase())
|
|
180
|
+
if (idx === -1) return <>{text}</>
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
{text.slice(0, idx)}
|
|
184
|
+
<mark className="bg-transparent font-semibold text-foreground">
|
|
185
|
+
{text.slice(idx, idx + query.length)}
|
|
186
|
+
</mark>
|
|
187
|
+
{text.slice(idx + query.length)}
|
|
188
|
+
</>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface SearchHit {
|
|
193
|
+
id: string
|
|
194
|
+
name: string
|
|
195
|
+
/** "Folder › Subfolder" breadcrumb of the ancestors (excludes the item itself). */
|
|
196
|
+
crumb: string
|
|
197
|
+
type: SidebarNodeType
|
|
198
|
+
href?: string
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** What the file-search modal matches against. */
|
|
202
|
+
type SearchScope = 'all' | 'files' | 'directories'
|
|
203
|
+
const SCOPE_OPTIONS: { value: SearchScope; label: string }[] = [
|
|
204
|
+
{ value: 'all', label: 'Все' },
|
|
205
|
+
{ value: 'files', label: 'Файлы' },
|
|
206
|
+
{ value: 'directories', label: 'Папки' },
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* WebStorm-style "search by file" modal: a centered overlay with a query input
|
|
211
|
+
* and a keyboard-navigable result list. Opened from the sidebar header icon or
|
|
212
|
+
* Ctrl/Cmd+P. Matches file (and, unless "только файлы" is on, folder) names by
|
|
213
|
+
* substring; picking a result navigates to its doc (folders just reveal in the
|
|
214
|
+
* tree). Self-contained — portal + outside-click + Esc, mirroring RowMenu/
|
|
215
|
+
* ProjectSwitcher rather than pulling in a dialog dependency.
|
|
216
|
+
*/
|
|
217
|
+
function FileSearchModal({
|
|
218
|
+
data,
|
|
219
|
+
getAncestorIds,
|
|
220
|
+
onClose,
|
|
221
|
+
onPick,
|
|
222
|
+
}: {
|
|
223
|
+
data: FlatSidebarMap
|
|
224
|
+
getAncestorIds: (id: string) => string[]
|
|
225
|
+
onClose: () => void
|
|
226
|
+
onPick: (id: string) => void
|
|
227
|
+
}) {
|
|
228
|
+
const [query, setQuery] = useState('')
|
|
229
|
+
const [scope, setScope] = useState<SearchScope>('all')
|
|
230
|
+
const [active, setActive] = useState(0)
|
|
231
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
232
|
+
const listRef = useRef<HTMLUListElement>(null)
|
|
233
|
+
|
|
234
|
+
// Focus the input on open.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
inputRef.current?.focus()
|
|
237
|
+
}, [])
|
|
238
|
+
|
|
239
|
+
// Close on Escape / outside click.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const onKey = (e: KeyboardEvent) => {
|
|
242
|
+
if (e.key === 'Escape') onClose()
|
|
243
|
+
}
|
|
244
|
+
document.addEventListener('keydown', onKey)
|
|
245
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
246
|
+
}, [onClose])
|
|
247
|
+
|
|
248
|
+
const hits = useMemo<SearchHit[]>(() => {
|
|
249
|
+
const q = query.trim().toLowerCase()
|
|
250
|
+
const out: SearchHit[] = []
|
|
251
|
+
for (const [id, item] of Object.entries(data)) {
|
|
252
|
+
if (id === 'root') continue
|
|
253
|
+
const isDir = item.type === 'directory'
|
|
254
|
+
if (scope === 'files' && isDir) continue
|
|
255
|
+
if (scope === 'directories' && !isDir) continue
|
|
256
|
+
if (q && !item.name.toLowerCase().includes(q)) continue
|
|
257
|
+
const crumb = getAncestorIds(id)
|
|
258
|
+
.reverse()
|
|
259
|
+
.map((a) => data[a]?.name)
|
|
260
|
+
.filter(Boolean)
|
|
261
|
+
.join(' › ')
|
|
262
|
+
out.push({ id, name: item.name, crumb, type: item.type, href: item.href })
|
|
263
|
+
}
|
|
264
|
+
// Prefix matches first, then alphabetical — keeps the best match on top.
|
|
265
|
+
out.sort((a, b) => {
|
|
266
|
+
if (q) {
|
|
267
|
+
const ap = a.name.toLowerCase().startsWith(q)
|
|
268
|
+
const bp = b.name.toLowerCase().startsWith(q)
|
|
269
|
+
if (ap !== bp) return ap ? -1 : 1
|
|
270
|
+
}
|
|
271
|
+
return a.name.localeCompare(b.name, 'ru')
|
|
272
|
+
})
|
|
273
|
+
return out.slice(0, 50)
|
|
274
|
+
}, [query, scope, data, getAncestorIds])
|
|
275
|
+
|
|
276
|
+
// Keep the active index in range as the result set changes, and scroll it in view.
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
setActive(0)
|
|
279
|
+
}, [query, scope])
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
listRef.current
|
|
282
|
+
?.querySelector('[data-active="true"]')
|
|
283
|
+
?.scrollIntoView({ block: 'nearest' })
|
|
284
|
+
}, [active, hits])
|
|
285
|
+
|
|
286
|
+
const choose = (i: number) => {
|
|
287
|
+
const hit = hits[i]
|
|
288
|
+
if (hit) onPick(hit.id)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const onInputKey = (e: React.KeyboardEvent) => {
|
|
292
|
+
if (e.key === 'ArrowDown') {
|
|
293
|
+
e.preventDefault()
|
|
294
|
+
setActive((a) => Math.min(a + 1, hits.length - 1))
|
|
295
|
+
} else if (e.key === 'ArrowUp') {
|
|
296
|
+
e.preventDefault()
|
|
297
|
+
setActive((a) => Math.max(a - 1, 0))
|
|
298
|
+
} else if (e.key === 'Enter') {
|
|
299
|
+
e.preventDefault()
|
|
300
|
+
choose(active)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return createPortal(
|
|
305
|
+
<div
|
|
306
|
+
className="fixed inset-0 z-100 bg-background/40 backdrop-blur-sm md:flex md:items-start md:justify-center md:p-4 md:pt-[12vh]"
|
|
307
|
+
onMouseDown={onClose}
|
|
308
|
+
>
|
|
309
|
+
<div
|
|
310
|
+
className="fixed inset-0 flex flex-col overflow-hidden bg-popover md:static md:max-h-[70vh] md:w-[min(40rem,calc(100vw-2rem))] md:rounded-lg md:border md:shadow-xl"
|
|
311
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
312
|
+
>
|
|
313
|
+
<div className="flex items-center gap-2 border-b px-3">
|
|
314
|
+
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
315
|
+
<input
|
|
316
|
+
ref={inputRef}
|
|
317
|
+
type="text"
|
|
318
|
+
value={query}
|
|
319
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
320
|
+
onKeyDown={onInputKey}
|
|
321
|
+
placeholder="Поиск по файлам..."
|
|
322
|
+
autoComplete="off"
|
|
323
|
+
className="h-11 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
324
|
+
/>
|
|
325
|
+
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5" role="tablist">
|
|
326
|
+
{SCOPE_OPTIONS.map((opt) => (
|
|
327
|
+
<button
|
|
328
|
+
key={opt.value}
|
|
329
|
+
type="button"
|
|
330
|
+
role="tab"
|
|
331
|
+
aria-selected={scope === opt.value}
|
|
332
|
+
tabIndex={-1}
|
|
333
|
+
onClick={() => setScope(opt.value)}
|
|
334
|
+
className={cn(
|
|
335
|
+
'rounded px-2 py-1 text-xs transition-colors',
|
|
336
|
+
scope === opt.value
|
|
337
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
338
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
339
|
+
)}
|
|
340
|
+
>
|
|
341
|
+
{opt.label}
|
|
342
|
+
</button>
|
|
343
|
+
))}
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
<ul ref={listRef} className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
347
|
+
{hits.length === 0 ? (
|
|
348
|
+
<li className="px-3 py-6 text-center text-sm text-muted-foreground">Ничего не найдено</li>
|
|
349
|
+
) : (
|
|
350
|
+
hits.map((hit, i) => {
|
|
351
|
+
const isActive = i === active
|
|
352
|
+
const Icon = iconComponent[hit.type]
|
|
353
|
+
return (
|
|
354
|
+
<li key={hit.id}>
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
data-active={isActive || undefined}
|
|
358
|
+
onMouseMove={() => setActive(i)}
|
|
359
|
+
onClick={() => choose(i)}
|
|
360
|
+
className={cn(
|
|
361
|
+
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left',
|
|
362
|
+
isActive && 'bg-sidebar-accent',
|
|
363
|
+
)}
|
|
364
|
+
>
|
|
365
|
+
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
366
|
+
<span className="min-w-0 flex-1">
|
|
367
|
+
<span className="block truncate text-sm text-foreground">
|
|
368
|
+
{highlightParts(hit.name, query.trim())}
|
|
369
|
+
</span>
|
|
370
|
+
{hit.crumb && (
|
|
371
|
+
<span className="block truncate text-xs text-muted-foreground">{hit.crumb}</span>
|
|
372
|
+
)}
|
|
373
|
+
</span>
|
|
374
|
+
</button>
|
|
375
|
+
</li>
|
|
376
|
+
)
|
|
377
|
+
})
|
|
378
|
+
)}
|
|
379
|
+
</ul>
|
|
380
|
+
{/* Mobile-only footer: the full-screen sheet has no backdrop to
|
|
381
|
+
tap, so give an explicit way out. Hidden from md up, where the
|
|
382
|
+
backdrop + Escape dismiss the centered card. */}
|
|
383
|
+
<div className="flex justify-end border-t p-3 md:hidden">
|
|
384
|
+
<button
|
|
385
|
+
type="button"
|
|
386
|
+
onClick={onClose}
|
|
387
|
+
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground"
|
|
388
|
+
>
|
|
389
|
+
{t('close')}
|
|
390
|
+
</button>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>,
|
|
394
|
+
document.body,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface RowMenuItem {
|
|
399
|
+
label: string
|
|
400
|
+
onSelect: () => void
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Per-row "more actions" menu (the ⋮ button at the end of a row). Hidden until
|
|
405
|
+
* the row is hovered/active (driven by the parent's `group` + `forceShow`), and
|
|
406
|
+
* revealed while its own dropdown is open. The dropdown is rendered in a portal
|
|
407
|
+
* with fixed positioning computed from the button rect, so it isn't clipped by
|
|
408
|
+
* the sidebar's `overflow` containers. Lightweight (outside-click + Escape),
|
|
409
|
+
* mirroring the ProjectSwitcher pattern — no extra dependency. Renders whatever
|
|
410
|
+
* `actions` the caller passes, so files and directories supply their own items.
|
|
411
|
+
*/
|
|
412
|
+
function RowMenu({
|
|
413
|
+
forceShow,
|
|
414
|
+
actions,
|
|
415
|
+
}: {
|
|
416
|
+
/** Keep the button visible even when the row isn't hovered (e.g. active row). */
|
|
417
|
+
forceShow?: boolean
|
|
418
|
+
actions: RowMenuItem[]
|
|
419
|
+
}) {
|
|
420
|
+
const [open, setOpen] = useState(false)
|
|
421
|
+
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
422
|
+
const btnRef = useRef<HTMLButtonElement>(null)
|
|
423
|
+
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (!open) return
|
|
426
|
+
const close = () => setOpen(false)
|
|
427
|
+
const onKey = (e: KeyboardEvent) => {
|
|
428
|
+
if (e.key === 'Escape') setOpen(false)
|
|
429
|
+
}
|
|
430
|
+
// Close on any outside interaction; also close on scroll/resize since the
|
|
431
|
+
// fixed menu would otherwise float away from its (scrolled) row.
|
|
432
|
+
document.addEventListener('mousedown', close)
|
|
433
|
+
document.addEventListener('keydown', onKey)
|
|
434
|
+
window.addEventListener('scroll', close, true)
|
|
435
|
+
window.addEventListener('resize', close)
|
|
436
|
+
return () => {
|
|
437
|
+
document.removeEventListener('mousedown', close)
|
|
438
|
+
document.removeEventListener('keydown', onKey)
|
|
439
|
+
window.removeEventListener('scroll', close, true)
|
|
440
|
+
window.removeEventListener('resize', close)
|
|
441
|
+
}
|
|
442
|
+
}, [open])
|
|
443
|
+
|
|
444
|
+
const toggle = (e: React.MouseEvent) => {
|
|
445
|
+
// Don't let the row's onClick/navigation fire from the menu button.
|
|
446
|
+
e.stopPropagation()
|
|
447
|
+
e.preventDefault()
|
|
448
|
+
if (!open && btnRef.current) {
|
|
449
|
+
const r = btnRef.current.getBoundingClientRect()
|
|
450
|
+
setPos({ top: r.bottom + 2, left: r.right })
|
|
451
|
+
}
|
|
452
|
+
setOpen((o) => !o)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<>
|
|
457
|
+
<button
|
|
458
|
+
ref={btnRef}
|
|
459
|
+
type="button"
|
|
460
|
+
aria-label="Действия"
|
|
461
|
+
aria-haspopup="menu"
|
|
462
|
+
aria-expanded={open}
|
|
463
|
+
// stop dblclick from opening a tab when interacting with the menu button
|
|
464
|
+
onDoubleClick={(e) => {
|
|
465
|
+
e.stopPropagation()
|
|
466
|
+
e.preventDefault()
|
|
467
|
+
}}
|
|
468
|
+
onClick={toggle}
|
|
469
|
+
className={cn(
|
|
470
|
+
'flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-foreground/10 hover:text-foreground',
|
|
471
|
+
open || forceShow ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
|
472
|
+
)}
|
|
473
|
+
>
|
|
474
|
+
<MoreVertical className="size-3.5" />
|
|
475
|
+
</button>
|
|
476
|
+
{open &&
|
|
477
|
+
pos &&
|
|
478
|
+
createPortal(
|
|
479
|
+
<div
|
|
480
|
+
role="menu"
|
|
481
|
+
// stop the document mousedown-to-close from firing for clicks inside
|
|
482
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
483
|
+
onClick={(e) => e.stopPropagation()}
|
|
484
|
+
style={{ position: 'fixed', top: pos.top, left: pos.left, transform: 'translateX(-100%)' }}
|
|
485
|
+
className="z-[200] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
|
486
|
+
>
|
|
487
|
+
{actions.map((action) => (
|
|
488
|
+
<button
|
|
489
|
+
key={action.label}
|
|
490
|
+
type="button"
|
|
491
|
+
role="menuitem"
|
|
492
|
+
onClick={() => {
|
|
493
|
+
setOpen(false)
|
|
494
|
+
action.onSelect()
|
|
495
|
+
}}
|
|
496
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-sidebar-accent"
|
|
497
|
+
>
|
|
498
|
+
{action.label}
|
|
499
|
+
</button>
|
|
500
|
+
))}
|
|
501
|
+
</div>,
|
|
502
|
+
document.body,
|
|
503
|
+
)}
|
|
504
|
+
</>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export default function Sidebar({ data, currentPath, open = false, className }: Props) {
|
|
509
|
+
const navigate = useNavigate()
|
|
510
|
+
const { hasTabs, openTab } = useTabs()
|
|
511
|
+
|
|
512
|
+
// --- Parent map + active item (memoised on data/currentPath) ---
|
|
513
|
+
const parentMap = useMemo(() => {
|
|
514
|
+
const m = new Map<string, string>()
|
|
515
|
+
for (const [id, item] of Object.entries(data)) {
|
|
516
|
+
for (const childId of item.children) m.set(childId, id)
|
|
517
|
+
}
|
|
518
|
+
return m
|
|
519
|
+
}, [data])
|
|
520
|
+
|
|
521
|
+
const getAncestorIds = useCallback(
|
|
522
|
+
(targetId: string): string[] => {
|
|
523
|
+
const ancestors: string[] = []
|
|
524
|
+
let cur = parentMap.get(targetId)
|
|
525
|
+
while (cur && cur !== 'root') {
|
|
526
|
+
ancestors.push(cur)
|
|
527
|
+
cur = parentMap.get(cur)
|
|
528
|
+
}
|
|
529
|
+
return ancestors
|
|
530
|
+
},
|
|
531
|
+
[parentMap],
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
const activeId = useMemo(() => {
|
|
535
|
+
const cur = normPath(currentPath)
|
|
536
|
+
for (const [id, item] of Object.entries(data)) {
|
|
537
|
+
if (item.href && normPath(item.href) === cur) return id
|
|
538
|
+
}
|
|
539
|
+
return null
|
|
540
|
+
}, [data, currentPath])
|
|
541
|
+
|
|
542
|
+
// --- Initial expanded state: collapsed by default, revealing only the
|
|
543
|
+
// ancestors of the active page so the current location is visible. ---
|
|
544
|
+
const initialExpanded = useMemo(
|
|
545
|
+
() => (activeId ? getAncestorIds(activeId) : []),
|
|
546
|
+
[activeId, getAncestorIds],
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
// In-tree text filtering is no longer driven by a header input (replaced by the
|
|
550
|
+
// file-search modal), so `search` stays '' and the filter/expand-all paths below
|
|
551
|
+
// are inert. Kept so those code paths don't need unpicking; setSearch('') is still
|
|
552
|
+
// used by locate() as a defensive reset.
|
|
553
|
+
const [search, setSearch] = useState('')
|
|
554
|
+
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
|
555
|
+
|
|
556
|
+
// useTree populates items only after a client-side effect runs, so during SSR
|
|
557
|
+
// (and the first paint before hydration) we render a static fallback tree from
|
|
558
|
+
// the same data. This keeps the nav present in the initial HTML and avoids an
|
|
559
|
+
// empty-sidebar flash; the interactive tree takes over once mounted.
|
|
560
|
+
const [mounted, setMounted] = useState(false)
|
|
561
|
+
useEffect(() => setMounted(true), [])
|
|
562
|
+
|
|
563
|
+
// Ctrl/Cmd+P opens the file-search modal (WebStorm/VS Code quick-open feel).
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
const onKey = (e: KeyboardEvent) => {
|
|
566
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') {
|
|
567
|
+
e.preventDefault()
|
|
568
|
+
setSearchModalOpen(true)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
window.addEventListener('keydown', onKey)
|
|
572
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
573
|
+
}, [])
|
|
574
|
+
|
|
575
|
+
// Re-evaluate the layout (clamp + TOC visibility) on window resize: a narrower
|
|
576
|
+
// window can squeeze the content column just like a wider sidebar does. Reads
|
|
577
|
+
// the current width from the CSS variable the init script / drag already set.
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
const onResize = () => {
|
|
580
|
+
const cur = parseInt(
|
|
581
|
+
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'),
|
|
582
|
+
10,
|
|
583
|
+
)
|
|
584
|
+
applyLayout(Number.isNaN(cur) ? DEFAULT_WIDTH : cur)
|
|
585
|
+
}
|
|
586
|
+
window.addEventListener('resize', onResize)
|
|
587
|
+
return () => window.removeEventListener('resize', onResize)
|
|
588
|
+
}, [])
|
|
589
|
+
|
|
590
|
+
const tree = useTree<FlatSidebarItem>({
|
|
591
|
+
rootItemId: 'root',
|
|
592
|
+
getItemName: (item) => item.getItemData().name,
|
|
593
|
+
isItemFolder: (item) => item.getItemData().children.length > 0,
|
|
594
|
+
dataLoader: {
|
|
595
|
+
getItem: (itemId) => data[itemId],
|
|
596
|
+
getChildren: (itemId) => data[itemId].children,
|
|
597
|
+
},
|
|
598
|
+
initialState: { expandedItems: initialExpanded },
|
|
599
|
+
indent: 12,
|
|
600
|
+
features: [syncDataLoaderFeature, hotkeysCoreFeature, expandAllFeature, selectionFeature],
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// --- Search: compute matches + visible ancestors ---
|
|
604
|
+
const { matchingIds, visibleIds } = useMemo(() => {
|
|
605
|
+
const matching = new Set<string>()
|
|
606
|
+
const visible = new Set<string>()
|
|
607
|
+
const q = search.trim().toLowerCase()
|
|
608
|
+
if (q) {
|
|
609
|
+
for (const [id, item] of Object.entries(data)) {
|
|
610
|
+
if (id === 'root') continue
|
|
611
|
+
if (item.name.toLowerCase().includes(q)) {
|
|
612
|
+
matching.add(id)
|
|
613
|
+
visible.add(id)
|
|
614
|
+
for (const anc of getAncestorIds(id)) visible.add(anc)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { matchingIds: matching, visibleIds: visible }
|
|
619
|
+
}, [search, data, getAncestorIds])
|
|
620
|
+
|
|
621
|
+
const expandedBeforeSearch = useRef<string[] | null>(null)
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
const q = search.trim()
|
|
624
|
+
if (q) {
|
|
625
|
+
if (expandedBeforeSearch.current === null) {
|
|
626
|
+
expandedBeforeSearch.current = [...(tree.getState().expandedItems ?? [])]
|
|
627
|
+
}
|
|
628
|
+
tree.expandAll()
|
|
629
|
+
} else if (expandedBeforeSearch.current !== null) {
|
|
630
|
+
// Restore the pre-search shape: collapse everything search opened, then
|
|
631
|
+
// re-expand only the folders that were open before. Use the instance
|
|
632
|
+
// API (collapseAll + expand) — mutating expandedItems via setState does
|
|
633
|
+
// not reliably re-collapse children in this version. The active item's
|
|
634
|
+
// ancestors get re-revealed by the activeId effect (and clicking a search
|
|
635
|
+
// result makes that result active, opening its folder).
|
|
636
|
+
// Sort shallow→deep so each folder's parent is already expanded (and its
|
|
637
|
+
// instance loaded) before we expand it.
|
|
638
|
+
const toReopen = [...expandedBeforeSearch.current].sort(
|
|
639
|
+
(a, b) => getAncestorIds(a).length - getAncestorIds(b).length,
|
|
640
|
+
)
|
|
641
|
+
expandedBeforeSearch.current = null
|
|
642
|
+
tree.collapseAll()
|
|
643
|
+
for (const id of toReopen) {
|
|
644
|
+
const inst = tree.getItemInstance(id)
|
|
645
|
+
if (inst && !inst.isExpanded()) inst.expand()
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
649
|
+
}, [search])
|
|
650
|
+
|
|
651
|
+
const treeContainerRef = useRef<HTMLDivElement>(null)
|
|
652
|
+
|
|
653
|
+
// --- Collapse all: fold every open folder back to the root level. ---
|
|
654
|
+
const collapseAll = useCallback(() => {
|
|
655
|
+
setSearch('')
|
|
656
|
+
tree.collapseAll()
|
|
657
|
+
}, [tree])
|
|
658
|
+
|
|
659
|
+
// --- Locate: expand ancestors of the active item and scroll to it ---
|
|
660
|
+
const locate = useCallback(() => {
|
|
661
|
+
if (!activeId) return
|
|
662
|
+
setSearch('')
|
|
663
|
+
// Expand outermost-first via the item-instance API (see the activeId
|
|
664
|
+
// reveal effect above for why setState on expandedItems isn't enough).
|
|
665
|
+
for (const id of getAncestorIds(activeId).reverse()) {
|
|
666
|
+
const inst = tree.getItemInstance(id)
|
|
667
|
+
if (inst && !inst.isExpanded()) inst.expand()
|
|
668
|
+
}
|
|
669
|
+
requestAnimationFrame(() => {
|
|
670
|
+
const el = treeContainerRef.current?.querySelector('[data-active="true"]')
|
|
671
|
+
if (el) {
|
|
672
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
673
|
+
el.classList.add('locate-flash')
|
|
674
|
+
el.addEventListener('animationend', () => el.classList.remove('locate-flash'), { once: true })
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
}, [activeId, getAncestorIds, tree])
|
|
678
|
+
|
|
679
|
+
// --- Scroll active item into view on first mount ---
|
|
680
|
+
useEffect(() => {
|
|
681
|
+
if (!activeId) return
|
|
682
|
+
requestAnimationFrame(() => {
|
|
683
|
+
treeContainerRef.current
|
|
684
|
+
?.querySelector('[data-active="true"]')
|
|
685
|
+
?.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior })
|
|
686
|
+
})
|
|
687
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
688
|
+
}, [])
|
|
689
|
+
|
|
690
|
+
// --- Global keyboard shortcuts for the tree (fire anywhere outside a text
|
|
691
|
+
// field): `l` reveals the current page, `g c` collapses every folder. ---
|
|
692
|
+
const treeShortcuts = useMemo<Shortcut[]>(
|
|
693
|
+
() => [
|
|
694
|
+
{ keys: 'l', label: 'Найти текущую страницу в дереве', group: 'Дерево', run: locate },
|
|
695
|
+
{ keys: 'c', label: 'Свернуть все папки', group: 'Дерево', run: collapseAll },
|
|
696
|
+
],
|
|
697
|
+
[locate, collapseAll],
|
|
698
|
+
)
|
|
699
|
+
useKeyboardShortcuts(treeShortcuts)
|
|
700
|
+
|
|
701
|
+
// --- Reveal the active item whenever it changes (e.g. navigating via a tab
|
|
702
|
+
// click after refresh): expand its ancestor folders so it isn't hidden
|
|
703
|
+
// inside a collapsed menu. `initialState` only applies at tree creation,
|
|
704
|
+
// so without this a later navigation leaves the item collapsed away.
|
|
705
|
+
// Skipped while searching, which drives its own expand-all behaviour. ---
|
|
706
|
+
useEffect(() => {
|
|
707
|
+
if (!activeId || search.trim()) return
|
|
708
|
+
// getAncestorIds returns innermost-first; expand outermost-first so each
|
|
709
|
+
// child folder's instance exists (it's only loaded once its parent is
|
|
710
|
+
// expanded). Use the item-instance API — mutating expandedItems via
|
|
711
|
+
// tree.setState does not reliably reveal children in this version.
|
|
712
|
+
for (const id of getAncestorIds(activeId).reverse()) {
|
|
713
|
+
const inst = tree.getItemInstance(id)
|
|
714
|
+
if (inst && !inst.isExpanded()) inst.expand()
|
|
715
|
+
}
|
|
716
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
717
|
+
}, [activeId, getAncestorIds])
|
|
718
|
+
|
|
719
|
+
// --- Resize: drag the right edge to set --sidebar-width, persisted. ---
|
|
720
|
+
const [resizing, setResizing] = useState(false)
|
|
721
|
+
const startResize = useCallback((e: React.PointerEvent) => {
|
|
722
|
+
// Only the grid sidebar (md+) is resizable; the mobile overlay is full-width.
|
|
723
|
+
e.preventDefault()
|
|
724
|
+
setResizing(true)
|
|
725
|
+
let lastWidth = DEFAULT_WIDTH
|
|
726
|
+
const onMove = (ev: PointerEvent) => {
|
|
727
|
+
// applyLayout clamps to the viewport and toggles the TOC; remember the
|
|
728
|
+
// clamped result so pointer-up persists the value actually applied.
|
|
729
|
+
lastWidth = applyLayout(ev.clientX) ?? lastWidth
|
|
730
|
+
}
|
|
731
|
+
const onUp = () => {
|
|
732
|
+
window.removeEventListener('pointermove', onMove)
|
|
733
|
+
window.removeEventListener('pointerup', onUp)
|
|
734
|
+
setResizing(false)
|
|
735
|
+
try {
|
|
736
|
+
localStorage.setItem(WIDTH_STORAGE_KEY, String(lastWidth))
|
|
737
|
+
} catch {
|
|
738
|
+
/* ignore */
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
window.addEventListener('pointermove', onMove)
|
|
742
|
+
window.addEventListener('pointerup', onUp)
|
|
743
|
+
}, [])
|
|
744
|
+
|
|
745
|
+
// While dragging, suppress text selection and force the resize cursor globally.
|
|
746
|
+
useEffect(() => {
|
|
747
|
+
if (!resizing) return
|
|
748
|
+
const prev = document.body.style.cssText
|
|
749
|
+
document.body.style.userSelect = 'none'
|
|
750
|
+
document.body.style.cursor = 'col-resize'
|
|
751
|
+
return () => {
|
|
752
|
+
document.body.style.cssText = prev
|
|
753
|
+
}
|
|
754
|
+
}, [resizing])
|
|
755
|
+
|
|
756
|
+
const isFiltering = search.trim().length > 0
|
|
757
|
+
const items = tree.getItems()
|
|
758
|
+
|
|
759
|
+
// Open a file from the sidebar, applying the tab rules:
|
|
760
|
+
// - double-click → always open as a tab
|
|
761
|
+
// - single-click → open as a tab only if tabs are already open; otherwise
|
|
762
|
+
// just navigate in place (no tab bar shown — the original behavior).
|
|
763
|
+
// Either way the URL changes, and the route renders the doc; the tab list is
|
|
764
|
+
// a UI layer on top. Returns nothing; callers handle the click guard.
|
|
765
|
+
const openFile = useCallback(
|
|
766
|
+
(href: string, name: string, isDoubleClick: boolean) => {
|
|
767
|
+
if (isDoubleClick || hasTabs) openTab(href, name)
|
|
768
|
+
navigate(href)
|
|
769
|
+
},
|
|
770
|
+
[hasTabs, openTab, navigate],
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
// Pick a result from the file-search modal: reveal the item in the tree
|
|
774
|
+
// (expand its ancestor folders, outermost-first, so its instance exists),
|
|
775
|
+
// focus it (the ring follows the selection), and scroll it into view. For a
|
|
776
|
+
// file we also navigate to its doc and let it expand; a folder just opens.
|
|
777
|
+
const pickSearchResult = useCallback(
|
|
778
|
+
(id: string) => {
|
|
779
|
+
setSearchModalOpen(false)
|
|
780
|
+
const item = data[id]
|
|
781
|
+
if (!item) return
|
|
782
|
+
// Expand ancestors first so getItemInstance(id) resolves to a loaded item.
|
|
783
|
+
for (const anc of getAncestorIds(id).reverse()) {
|
|
784
|
+
const inst = tree.getItemInstance(anc)
|
|
785
|
+
if (inst && !inst.isExpanded()) inst.expand()
|
|
786
|
+
}
|
|
787
|
+
const self = tree.getItemInstance(id)
|
|
788
|
+
if (self) {
|
|
789
|
+
if (!item.href && !self.isExpanded()) self.expand()
|
|
790
|
+
self.setFocused()
|
|
791
|
+
}
|
|
792
|
+
if (item.href) navigate(item.href)
|
|
793
|
+
requestAnimationFrame(() => {
|
|
794
|
+
treeContainerRef.current
|
|
795
|
+
?.querySelector(`[data-item-id="${CSS.escape(id)}"]`)
|
|
796
|
+
?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
797
|
+
})
|
|
798
|
+
},
|
|
799
|
+
[data, getAncestorIds, navigate, tree],
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
return (
|
|
803
|
+
<aside
|
|
804
|
+
id="sidebar"
|
|
805
|
+
className={cn(
|
|
806
|
+
// Desktop: pin just below the sticky TopBar (h-11 = 2.75rem) and shrink
|
|
807
|
+
// height to match, so the bar never covers the menu and the sidebar
|
|
808
|
+
// doesn't overflow past the viewport bottom.
|
|
809
|
+
'sticky top-11 flex h-[calc(100vh-2.75rem)] flex-col overflow-hidden border-r bg-sidebar text-sidebar-foreground',
|
|
810
|
+
// Mobile: full-screen overlay toggled by the bottom bar. Runs full
|
|
811
|
+
// height (inset-0); the floating bottom bar (z-100) sits over it, and
|
|
812
|
+
// the tree's bottom padding keeps the last items clear of that bar.
|
|
813
|
+
'max-md:fixed max-md:inset-0 max-md:z-90 max-md:h-auto',
|
|
814
|
+
open ? 'max-md:flex' : 'max-md:hidden',
|
|
815
|
+
className,
|
|
816
|
+
)}
|
|
817
|
+
>
|
|
818
|
+
{/* Resize handle: drag the right edge to set the sidebar width (md+ only). */}
|
|
819
|
+
<div
|
|
820
|
+
onPointerDown={startResize}
|
|
821
|
+
role="separator"
|
|
822
|
+
aria-orientation="vertical"
|
|
823
|
+
aria-label="Изменить ширину панели"
|
|
824
|
+
className={cn(
|
|
825
|
+
'absolute right-0 top-0 z-10 hidden h-full w-1.5 cursor-col-resize md:block',
|
|
826
|
+
'hover:bg-sidebar-accent',
|
|
827
|
+
resizing && 'bg-sidebar-accent',
|
|
828
|
+
)}
|
|
829
|
+
/>
|
|
830
|
+
<div className="flex shrink-0 items-center justify-end gap-2 border-b px-1 py-0.5 md:gap-0.5">
|
|
831
|
+
<Button
|
|
832
|
+
type="button"
|
|
833
|
+
variant="ghost"
|
|
834
|
+
size="icon"
|
|
835
|
+
onClick={() => setSearchModalOpen(true)}
|
|
836
|
+
title="Поиск по файлам (Ctrl+P)"
|
|
837
|
+
aria-label="Поиск по файлам"
|
|
838
|
+
className="size-9 text-muted-foreground md:size-6"
|
|
839
|
+
>
|
|
840
|
+
<Search className="size-4 md:size-3.5" />
|
|
841
|
+
</Button>
|
|
842
|
+
<Button
|
|
843
|
+
type="button"
|
|
844
|
+
variant="ghost"
|
|
845
|
+
size="icon"
|
|
846
|
+
onClick={locate}
|
|
847
|
+
title="Найти в дереве (L)"
|
|
848
|
+
aria-label="Найти текущую страницу в дереве"
|
|
849
|
+
className="size-9 text-muted-foreground md:size-6"
|
|
850
|
+
>
|
|
851
|
+
<Locate className="size-4 md:size-3.5" />
|
|
852
|
+
</Button>
|
|
853
|
+
<Button
|
|
854
|
+
type="button"
|
|
855
|
+
variant="ghost"
|
|
856
|
+
size="icon"
|
|
857
|
+
onClick={collapseAll}
|
|
858
|
+
title="Свернуть всё (C)"
|
|
859
|
+
aria-label="Свернуть все папки"
|
|
860
|
+
className="size-9 text-muted-foreground md:size-6"
|
|
861
|
+
>
|
|
862
|
+
<ChevronsDownUp className="size-4 md:size-3.5" />
|
|
863
|
+
</Button>
|
|
864
|
+
</div>
|
|
865
|
+
|
|
866
|
+
{/* Extra bottom padding so the last tree items have breathing room and
|
|
867
|
+
stay comfortably scrollable into view at the bottom of the menu. On
|
|
868
|
+
mobile, clear the floating bottom bar (its height + gap + safe area)
|
|
869
|
+
so the last items aren't hidden behind it. */}
|
|
870
|
+
<div
|
|
871
|
+
className="flex-1 overflow-y-auto px-2 pt-1 pb-14 max-md:pb-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+3rem)]"
|
|
872
|
+
ref={treeContainerRef}
|
|
873
|
+
>
|
|
874
|
+
{!mounted ? (
|
|
875
|
+
<FallbackTree
|
|
876
|
+
data={data}
|
|
877
|
+
currentPath={currentPath}
|
|
878
|
+
expanded={new Set(initialExpanded)}
|
|
879
|
+
/>
|
|
880
|
+
) : (
|
|
881
|
+
<div {...tree.getContainerProps()} className="outline-none">
|
|
882
|
+
{items.map((item) => {
|
|
883
|
+
const id = item.getId()
|
|
884
|
+
if (isFiltering && !visibleIds.has(id)) return null
|
|
885
|
+
const itemData = item.getItemData()
|
|
886
|
+
const isFolder = item.isFolder()
|
|
887
|
+
const isActive = activeId === id
|
|
888
|
+
const isMatch = matchingIds.has(id)
|
|
889
|
+
const level = item.getItemMeta().level
|
|
890
|
+
const rowProps = item.getProps()
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<div
|
|
894
|
+
{...rowProps}
|
|
895
|
+
key={id}
|
|
896
|
+
title={itemData.name}
|
|
897
|
+
data-item-id={id}
|
|
898
|
+
data-active={isActive || undefined}
|
|
899
|
+
className={cn(
|
|
900
|
+
rowBase,
|
|
901
|
+
isActive && 'bg-sidebar-accent',
|
|
902
|
+
item.isFocused() && 'relative z-10 ring-1 ring-ring',
|
|
903
|
+
)}
|
|
904
|
+
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
|
905
|
+
onKeyDown={(e) => {
|
|
906
|
+
// Preserve the tree's own key handling (arrow nav, expand/collapse).
|
|
907
|
+
rowProps.onKeyDown?.(e)
|
|
908
|
+
// Shift+Enter triggers the focused row's secondary action, mirroring
|
|
909
|
+
// its RowMenu entry: a file opens in a new tab ("Открыть в новой
|
|
910
|
+
// вкладке"); a folder expands recursively ("Развернуть рекурсивно").
|
|
911
|
+
if (e.key === 'Enter' && e.shiftKey) {
|
|
912
|
+
if (itemData.href) {
|
|
913
|
+
e.preventDefault()
|
|
914
|
+
openFile(itemData.href, itemData.name, true)
|
|
915
|
+
} else if (isFolder) {
|
|
916
|
+
e.preventDefault()
|
|
917
|
+
void item.expandAll()
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}}
|
|
921
|
+
onClick={(e) => {
|
|
922
|
+
// Make the whole row navigate, not just the link text. The chevron
|
|
923
|
+
// stops propagation, and the inner <Link> handles its own clicks
|
|
924
|
+
// (incl. modifier-clicks / new-tab), so we only act on the rest of
|
|
925
|
+
// the row and skip modified clicks here.
|
|
926
|
+
rowProps.onClick?.(e)
|
|
927
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
928
|
+
const target = e.target as HTMLElement
|
|
929
|
+
if (itemData.href && !target.closest('a, [data-chevron]')) {
|
|
930
|
+
openFile(itemData.href, itemData.name, false)
|
|
931
|
+
}
|
|
932
|
+
}}
|
|
933
|
+
onDoubleClick={(e) => {
|
|
934
|
+
// Double-click always opens the file as a tab (regardless of whether
|
|
935
|
+
// any tabs are currently open). Skip the chevron and modifier-clicks.
|
|
936
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
937
|
+
const target = e.target as HTMLElement
|
|
938
|
+
if (itemData.href && !target.closest('[data-chevron]')) {
|
|
939
|
+
e.preventDefault()
|
|
940
|
+
openFile(itemData.href, itemData.name, true)
|
|
941
|
+
}
|
|
942
|
+
}}
|
|
943
|
+
>
|
|
944
|
+
{isFolder ? (
|
|
945
|
+
<button
|
|
946
|
+
type="button"
|
|
947
|
+
data-chevron
|
|
948
|
+
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground"
|
|
949
|
+
onClick={(e) => {
|
|
950
|
+
e.stopPropagation()
|
|
951
|
+
if (item.isExpanded()) item.collapse()
|
|
952
|
+
else item.expand()
|
|
953
|
+
}}
|
|
954
|
+
>
|
|
955
|
+
<ChevronRight
|
|
956
|
+
className={cn('size-3.5 transition-transform', item.isExpanded() && 'rotate-90')}
|
|
957
|
+
/>
|
|
958
|
+
</button>
|
|
959
|
+
) : (
|
|
960
|
+
<span className="size-4 shrink-0" />
|
|
961
|
+
)}
|
|
962
|
+
<span className={cn('flex size-4 shrink-0 items-center justify-center', iconColor[itemData.type])}>
|
|
963
|
+
{icons[itemData.type] ?? icons.file}
|
|
964
|
+
</span>
|
|
965
|
+
{itemData.href ? (
|
|
966
|
+
<Link
|
|
967
|
+
to={itemData.href}
|
|
968
|
+
onClick={(e) => {
|
|
969
|
+
// Let the browser handle modifier-clicks (open in new tab, etc.).
|
|
970
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
971
|
+
// Otherwise drive navigation through the tab rules instead of the
|
|
972
|
+
// Link's default, so a single-click on the text honors hasTabs too.
|
|
973
|
+
e.preventDefault()
|
|
974
|
+
openFile(itemData.href!, itemData.name, false)
|
|
975
|
+
}}
|
|
976
|
+
className={cn(
|
|
977
|
+
'flex-1 min-w-0 truncate no-underline',
|
|
978
|
+
isActive ? 'text-foreground font-medium' : 'text-sidebar-foreground/80',
|
|
979
|
+
isMatch && 'text-foreground font-semibold',
|
|
980
|
+
)}
|
|
981
|
+
>
|
|
982
|
+
{isMatch ? highlightParts(itemData.name, search.trim()) : itemData.name}
|
|
983
|
+
</Link>
|
|
984
|
+
) : (
|
|
985
|
+
<span
|
|
986
|
+
className={cn(
|
|
987
|
+
'flex-1 min-w-0 truncate',
|
|
988
|
+
level === 0 ? 'font-semibold text-foreground' : 'text-sidebar-foreground/80',
|
|
989
|
+
isMatch && 'text-foreground font-semibold',
|
|
990
|
+
)}
|
|
991
|
+
>
|
|
992
|
+
{isMatch ? highlightParts(itemData.name, search.trim()) : itemData.name}
|
|
993
|
+
</span>
|
|
994
|
+
)}
|
|
995
|
+
{/* "More actions" menu. File rows open the doc as an app tab;
|
|
996
|
+
directory rows expand the whole subtree recursively. The ⋮
|
|
997
|
+
button moves focus to its row first (it stops propagation, so
|
|
998
|
+
the tree's own click never runs) — keeping the focus ring on
|
|
999
|
+
the row whose action fired, like a normal row click would. */}
|
|
1000
|
+
{itemData.href ? (
|
|
1001
|
+
<RowMenu
|
|
1002
|
+
actions={[
|
|
1003
|
+
{
|
|
1004
|
+
label: 'Открыть в новой вкладке (Shift+Enter)',
|
|
1005
|
+
onSelect: () => {
|
|
1006
|
+
item.setFocused()
|
|
1007
|
+
openFile(itemData.href!, itemData.name, true)
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
]}
|
|
1011
|
+
/>
|
|
1012
|
+
) : (
|
|
1013
|
+
isFolder && (
|
|
1014
|
+
<RowMenu
|
|
1015
|
+
actions={[
|
|
1016
|
+
{
|
|
1017
|
+
label: 'Развернуть рекурсивно (Shift+Enter)',
|
|
1018
|
+
onSelect: () => {
|
|
1019
|
+
item.setFocused()
|
|
1020
|
+
// expandAll on the item instance opens this folder and
|
|
1021
|
+
// every descendant folder (awaiting child loads as it goes).
|
|
1022
|
+
void item.expandAll()
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
]}
|
|
1026
|
+
/>
|
|
1027
|
+
)
|
|
1028
|
+
)}
|
|
1029
|
+
</div>
|
|
1030
|
+
)
|
|
1031
|
+
})}
|
|
1032
|
+
{isFiltering && matchingIds.size === 0 && (
|
|
1033
|
+
<div className="p-4 text-center text-[0.8125rem] text-muted-foreground">Ничего не найдено</div>
|
|
1034
|
+
)}
|
|
1035
|
+
</div>
|
|
1036
|
+
)}
|
|
1037
|
+
</div>
|
|
1038
|
+
|
|
1039
|
+
{searchModalOpen && (
|
|
1040
|
+
<FileSearchModal
|
|
1041
|
+
data={data}
|
|
1042
|
+
getAncestorIds={getAncestorIds}
|
|
1043
|
+
onClose={() => setSearchModalOpen(false)}
|
|
1044
|
+
onPick={pickSearchResult}
|
|
1045
|
+
/>
|
|
1046
|
+
)}
|
|
1047
|
+
</aside>
|
|
1048
|
+
)
|
|
1049
|
+
}
|