@cadcrawl/cad-browser 0.3.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.md +5 -0
- package/README.md +65 -0
- package/bin/cad-browser +4 -0
- package/dist/client/assets/index-C1XW2UmF.css +1 -0
- package/dist/client/assets/index-HFIcPO3j.js +204 -0
- package/dist/client/index.html +14 -0
- package/package.json +62 -0
- package/src/analyzer.js +31 -0
- package/src/args.js +16 -0
- package/src/cache.js +38 -0
- package/src/cli.js +53 -0
- package/src/file-types.js +15 -0
- package/src/main.jsx +567 -0
- package/src/path-safety.js +15 -0
- package/src/project-store.js +127 -0
- package/src/reveal-file.js +36 -0
- package/src/scanner.js +75 -0
- package/src/server.js +103 -0
- package/src/styles.css +435 -0
package/src/main.jsx
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import {
|
|
4
|
+
ArrowDown, ArrowUp, Box, Boxes, Check, ChevronDown, ChevronRight, CircleAlert, Copy, ExternalLink,
|
|
5
|
+
File, FileImage, FileText, Folder, FolderOpen, Grid2X2, Info, List,
|
|
6
|
+
LoaderCircle, Minus, PanelLeftClose, PanelLeftOpen, Plus,
|
|
7
|
+
RefreshCw, Search, SlidersHorizontal, X,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import './styles.css';
|
|
10
|
+
|
|
11
|
+
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
|
12
|
+
const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
|
13
|
+
const TYPE_FILTERS = ['all', 'cad', 'pdf', 'image', 'file'];
|
|
14
|
+
const SORT_OPTIONS = [
|
|
15
|
+
['name', 'Name'],
|
|
16
|
+
['modified', 'Modified'],
|
|
17
|
+
['size', 'Size'],
|
|
18
|
+
['type', 'Type'],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function App() {
|
|
22
|
+
const [project, setProject] = useState(null);
|
|
23
|
+
const [query, setQuery] = useState('');
|
|
24
|
+
const [folder, setFolder] = useState('');
|
|
25
|
+
const [selectedPath, setSelectedPath] = useState(null);
|
|
26
|
+
const [view, setView] = useState('grid');
|
|
27
|
+
const [gridColumns, setGridColumns] = useStoredNumber('cad-browser-grid-columns', 4, 4, 15);
|
|
28
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
29
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
30
|
+
const [typeFilter, setTypeFilter] = useState('all');
|
|
31
|
+
const [sortBy, setSortBy] = useState('name');
|
|
32
|
+
const [sortDescending, setSortDescending] = useState(false);
|
|
33
|
+
const [toast, setToast] = useState(null);
|
|
34
|
+
const searchRef = useRef(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
loadProject(setProject);
|
|
38
|
+
const stream = new EventSource('/api/events');
|
|
39
|
+
stream.addEventListener('file', (event) => {
|
|
40
|
+
const update = JSON.parse(event.data);
|
|
41
|
+
setProject((current) => current && ({
|
|
42
|
+
...current,
|
|
43
|
+
files: current.files.map((file) => file.path === update.path ? update : file),
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
return () => stream.close();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const files = useMemo(() => {
|
|
50
|
+
if (!project) return [];
|
|
51
|
+
const normalized = query.trim().toLocaleLowerCase();
|
|
52
|
+
const searchAllFolders = normalized.length > 0;
|
|
53
|
+
const filtered = project.files.filter((file) => {
|
|
54
|
+
const inFolder = searchAllFolders || !folder || file.parent === folder;
|
|
55
|
+
const matchesSearch = !normalized
|
|
56
|
+
|| file.name.toLocaleLowerCase().includes(normalized)
|
|
57
|
+
|| file.path.toLocaleLowerCase().includes(normalized)
|
|
58
|
+
|| file.analysis?.text?.toLocaleLowerCase().includes(normalized);
|
|
59
|
+
const matchesType = typeFilter === 'all' || file.kind === typeFilter;
|
|
60
|
+
return inFolder && matchesSearch && matchesType;
|
|
61
|
+
});
|
|
62
|
+
const direction = sortDescending ? -1 : 1;
|
|
63
|
+
return filtered.sort((left, right) => compareFiles(left, right, sortBy) * direction);
|
|
64
|
+
}, [project, folder, query, typeFilter, sortBy, sortDescending]);
|
|
65
|
+
|
|
66
|
+
const selected = project?.files.find((file) => file.path === selectedPath) ?? null;
|
|
67
|
+
const selectedIndex = files.findIndex((file) => file.path === selectedPath);
|
|
68
|
+
const currentTitle = folder ? folder.split('/').at(-1) : project?.rootName;
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
function onKeyDown(event) {
|
|
72
|
+
const target = event.target;
|
|
73
|
+
const isTyping = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement;
|
|
74
|
+
if ((event.ctrlKey || event.metaKey) && (event.code === 'KeyK' || event.key.toLowerCase() === 'k')) {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
event.stopPropagation();
|
|
77
|
+
searchRef.current?.focus();
|
|
78
|
+
searchRef.current?.select();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!isTyping && event.key === '/') {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
searchRef.current?.focus();
|
|
84
|
+
searchRef.current?.select();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.key === 'Escape') {
|
|
88
|
+
if (previewOpen) setPreviewOpen(false);
|
|
89
|
+
else if (query) setQuery('');
|
|
90
|
+
else if (selectedPath) setSelectedPath(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (isTyping || !files.length) return;
|
|
94
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
selectByIndex(Math.min(files.length - 1, Math.max(0, selectedIndex + 1)));
|
|
97
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
selectByIndex(Math.max(0, selectedIndex <= 0 ? 0 : selectedIndex - 1));
|
|
100
|
+
} else if (event.key === 'Enter' && selected) {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
setPreviewOpen(Boolean(previewUrl(selected)));
|
|
103
|
+
} else if (event.key.toLowerCase() === 'o' && selected) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
runFileAction('/api/open', selected.path, 'Opened in the default application');
|
|
106
|
+
} else if (event.key.toLowerCase() === 'r' && selected) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
runFileAction('/api/reveal', selected.path, 'Revealed in the file manager');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
112
|
+
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
113
|
+
}, [files, previewOpen, query, selected, selectedIndex, selectedPath]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!toast) return undefined;
|
|
117
|
+
const timeout = setTimeout(() => setToast(null), 2400);
|
|
118
|
+
return () => clearTimeout(timeout);
|
|
119
|
+
}, [toast]);
|
|
120
|
+
|
|
121
|
+
function selectByIndex(index) {
|
|
122
|
+
const file = files[index];
|
|
123
|
+
if (file) setSelectedPath(file.path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function rescan() {
|
|
127
|
+
const response = await fetch('/api/rescan', { method: 'POST' });
|
|
128
|
+
setProject(await response.json());
|
|
129
|
+
setToast({ message: 'Project rescanned', type: 'success' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function runFileAction(endpoint, filePath, successMessage) {
|
|
133
|
+
try {
|
|
134
|
+
await postFileAction(endpoint, filePath);
|
|
135
|
+
setToast({ message: successMessage, type: 'success' });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
setToast({ message: error.message, type: 'error' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function copyPath(filePath) {
|
|
142
|
+
await navigator.clipboard.writeText(filePath);
|
|
143
|
+
setToast({ message: 'Project path copied', type: 'success' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!project) return <LoadingScreen />;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={`app-shell ${sidebarOpen ? '' : 'sidebar-collapsed'}`}>
|
|
150
|
+
<header className="topbar">
|
|
151
|
+
<div className="brand">
|
|
152
|
+
<div className="brand-mark"><Box size={17} strokeWidth={1.9} /></div>
|
|
153
|
+
<span>CAD Browser</span>
|
|
154
|
+
<span className="project-chip">{project.rootName}</span>
|
|
155
|
+
</div>
|
|
156
|
+
<label className="search">
|
|
157
|
+
<Search size={16} />
|
|
158
|
+
<input
|
|
159
|
+
ref={searchRef}
|
|
160
|
+
value={query}
|
|
161
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
162
|
+
placeholder="Search files, paths, and PDF text"
|
|
163
|
+
aria-label="Search project"
|
|
164
|
+
/>
|
|
165
|
+
{query && <button onClick={() => setQuery('')} aria-label="Clear search"><X size={14} /></button>}
|
|
166
|
+
<kbd>Ctrl K</kbd>
|
|
167
|
+
</label>
|
|
168
|
+
<div className="top-actions">
|
|
169
|
+
<button className="icon-button" onClick={rescan} title="Rescan project"><RefreshCw size={16} /></button>
|
|
170
|
+
<div className="status-pill"><span className="status-dot" />Local</div>
|
|
171
|
+
</div>
|
|
172
|
+
</header>
|
|
173
|
+
|
|
174
|
+
<aside className="sidebar">
|
|
175
|
+
<div className="sidebar-head">
|
|
176
|
+
<span>Project</span>
|
|
177
|
+
<button className="icon-button quiet" onClick={() => setSidebarOpen(false)} title="Hide project tree"><PanelLeftClose size={17} /></button>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="tree-scroll">
|
|
180
|
+
<TreeNode node={project.tree} activeFolder={folder} onFolder={(value) => { setFolder(value); setQuery(''); }} rootName={project.rootName} />
|
|
181
|
+
</div>
|
|
182
|
+
<div className="sidebar-footer">
|
|
183
|
+
<div><strong>{project.counts.total}</strong> files</div>
|
|
184
|
+
<div><strong>{project.counts.engineering}</strong> previewable</div>
|
|
185
|
+
</div>
|
|
186
|
+
</aside>
|
|
187
|
+
|
|
188
|
+
{!sidebarOpen && (
|
|
189
|
+
<button className="sidebar-reveal" onClick={() => setSidebarOpen(true)} title="Show project tree">
|
|
190
|
+
<PanelLeftOpen size={18} />
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<main className="workspace">
|
|
195
|
+
<section className="content-head">
|
|
196
|
+
<div className="content-title">
|
|
197
|
+
<div className="breadcrumbs">
|
|
198
|
+
<button onClick={() => { setFolder(''); setQuery(''); }}>{project.rootName}</button>
|
|
199
|
+
{!query && folder.split('/').filter(Boolean).map((part, index, parts) => (
|
|
200
|
+
<React.Fragment key={`${part}-${index}`}>
|
|
201
|
+
<ChevronRight size={13} />
|
|
202
|
+
<button onClick={() => setFolder(parts.slice(0, index + 1).join('/'))}>{part}</button>
|
|
203
|
+
</React.Fragment>
|
|
204
|
+
))}
|
|
205
|
+
{query && <><ChevronRight size={13} /><span>Search results</span></>}
|
|
206
|
+
</div>
|
|
207
|
+
<h1>{query ? `Search: ${query}` : currentTitle}</h1>
|
|
208
|
+
<p>{files.length} {files.length === 1 ? 'item' : 'items'}{query ? ' across the entire project' : ''}</p>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="browser-toolbar">
|
|
211
|
+
<TypeFilter value={typeFilter} onChange={setTypeFilter} />
|
|
212
|
+
<SortControl value={sortBy} descending={sortDescending} onChange={setSortBy} onDirection={() => setSortDescending((value) => !value)} />
|
|
213
|
+
{view === 'grid' && (
|
|
214
|
+
<div className="grid-scale" aria-label="Tile density">
|
|
215
|
+
<button onClick={() => setGridColumns((value) => Math.min(15, value + 1))} disabled={gridColumns >= 15} title="Smaller tiles"><Minus size={14} /></button>
|
|
216
|
+
<span title={`${gridColumns} columns`}>{gridColumns}</span>
|
|
217
|
+
<button onClick={() => setGridColumns((value) => Math.max(4, value - 1))} disabled={gridColumns <= 4} title="Larger tiles"><Plus size={14} /></button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
<div className="view-switcher">
|
|
221
|
+
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} title="Grid view"><Grid2X2 size={16} /></button>
|
|
222
|
+
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} title="List view"><List size={17} /></button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
|
|
227
|
+
<section
|
|
228
|
+
className={view === 'grid' ? `file-grid density-${densityFor(gridColumns)}` : 'file-list'}
|
|
229
|
+
style={view === 'grid' ? { '--grid-columns': gridColumns } : undefined}
|
|
230
|
+
>
|
|
231
|
+
{files.map((file, index) => (
|
|
232
|
+
<FileCard
|
|
233
|
+
key={file.path}
|
|
234
|
+
file={file}
|
|
235
|
+
index={index}
|
|
236
|
+
view={view}
|
|
237
|
+
selected={selectedPath === file.path}
|
|
238
|
+
onSelect={() => setSelectedPath(file.path)}
|
|
239
|
+
onOpen={() => runFileAction('/api/open', file.path, 'Opened in the default application')}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
{files.length === 0 && <EmptyState query={query} />}
|
|
243
|
+
</section>
|
|
244
|
+
</main>
|
|
245
|
+
|
|
246
|
+
<Inspector
|
|
247
|
+
file={selected}
|
|
248
|
+
onClose={() => setSelectedPath(null)}
|
|
249
|
+
onPreview={() => setPreviewOpen(true)}
|
|
250
|
+
onOpen={() => selected && runFileAction('/api/open', selected.path, 'Opened in the default application')}
|
|
251
|
+
onReveal={() => selected && runFileAction('/api/reveal', selected.path, 'Revealed in the file manager')}
|
|
252
|
+
onCopy={() => selected && copyPath(selected.path)}
|
|
253
|
+
onReanalyze={() => selected && postFileAction('/api/analyze', selected.path, { force: true }).then(() => setToast({ message: 'Analysis queued', type: 'success' }))}
|
|
254
|
+
/>
|
|
255
|
+
{previewOpen && selected && <PreviewModal file={selected} onClose={() => setPreviewOpen(false)} />}
|
|
256
|
+
{toast && (
|
|
257
|
+
<div className={`toast ${toast.type}`}>
|
|
258
|
+
{toast.type === 'error' ? <CircleAlert size={15} /> : <Check size={15} />}
|
|
259
|
+
{toast.message}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function TypeFilter({ value, onChange }) {
|
|
267
|
+
const options = TYPE_FILTERS.map((type) => ({
|
|
268
|
+
value: type,
|
|
269
|
+
label: type === 'all' ? 'All types' : type.toUpperCase(),
|
|
270
|
+
}));
|
|
271
|
+
return <Dropdown icon={<SlidersHorizontal size={14} />} value={value} options={options} onChange={onChange} label="Filter by file type" />;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function SortControl({ value, descending, onChange, onDirection }) {
|
|
275
|
+
const options = SORT_OPTIONS.map(([key, label]) => ({ value: key, label: `Sort: ${label}` }));
|
|
276
|
+
return (
|
|
277
|
+
<div className="sort-control">
|
|
278
|
+
<Dropdown value={value} options={options} onChange={onChange} label="Sort files" />
|
|
279
|
+
<button onClick={onDirection} title={descending ? 'Descending' : 'Ascending'} aria-label={descending ? 'Descending' : 'Ascending'}>
|
|
280
|
+
{descending ? <ArrowDown size={15} /> : <ArrowUp size={15} />}
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function Dropdown({ icon, value, options, onChange, label }) {
|
|
287
|
+
const [open, setOpen] = useState(false);
|
|
288
|
+
const rootRef = useRef(null);
|
|
289
|
+
const selected = options.find((option) => option.value === value) ?? options[0];
|
|
290
|
+
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
function close(event) {
|
|
293
|
+
if (!rootRef.current?.contains(event.target)) setOpen(false);
|
|
294
|
+
}
|
|
295
|
+
function closeWithEscape(event) {
|
|
296
|
+
if (event.key === 'Escape') setOpen(false);
|
|
297
|
+
}
|
|
298
|
+
document.addEventListener('pointerdown', close);
|
|
299
|
+
document.addEventListener('keydown', closeWithEscape, true);
|
|
300
|
+
return () => {
|
|
301
|
+
document.removeEventListener('pointerdown', close);
|
|
302
|
+
document.removeEventListener('keydown', closeWithEscape, true);
|
|
303
|
+
};
|
|
304
|
+
}, []);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div className={`dropdown ${open ? 'open' : ''}`} ref={rootRef}>
|
|
308
|
+
<button className="dropdown-trigger" onClick={() => setOpen((value) => !value)} aria-label={label} aria-expanded={open}>
|
|
309
|
+
{icon}
|
|
310
|
+
<span>{selected.label}</span>
|
|
311
|
+
<ChevronDown size={14} />
|
|
312
|
+
</button>
|
|
313
|
+
{open && (
|
|
314
|
+
<div className="dropdown-menu" role="menu">
|
|
315
|
+
{options.map((option) => (
|
|
316
|
+
<button
|
|
317
|
+
key={option.value}
|
|
318
|
+
className={option.value === value ? 'selected' : ''}
|
|
319
|
+
onClick={() => { onChange(option.value); setOpen(false); }}
|
|
320
|
+
role="menuitem"
|
|
321
|
+
>
|
|
322
|
+
<span>{option.label}</span>
|
|
323
|
+
{option.value === value && <Check size={14} />}
|
|
324
|
+
</button>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function TreeNode({ node, activeFolder, onFolder, rootName, depth = 0 }) {
|
|
333
|
+
const [open, setOpen] = useState(depth < 1);
|
|
334
|
+
if (node.type !== 'directory') return null;
|
|
335
|
+
const label = depth === 0 ? rootName : node.name;
|
|
336
|
+
return (
|
|
337
|
+
<div>
|
|
338
|
+
<button className={`tree-row ${activeFolder === node.path ? 'active' : ''}`} style={{ '--depth': depth }} onClick={() => { setOpen(!open); onFolder(node.path); }}>
|
|
339
|
+
<span className="tree-toggle">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
|
|
340
|
+
{open ? <FolderOpen size={16} /> : <Folder size={16} />}
|
|
341
|
+
<span className="tree-label">{label}</span>
|
|
342
|
+
<span className="tree-count">{node.fileCount || countNodeFiles(node)}</span>
|
|
343
|
+
</button>
|
|
344
|
+
{open && node.children.filter((child) => child.type === 'directory').map((child) => (
|
|
345
|
+
<TreeNode key={child.path} node={child} activeFolder={activeFolder} onFolder={onFolder} rootName={rootName} depth={depth + 1} />
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function FileCard({ file, index, view, selected, onSelect, onOpen }) {
|
|
352
|
+
const preview = previewUrl(file);
|
|
353
|
+
return (
|
|
354
|
+
<article
|
|
355
|
+
className={`file-card ${selected ? 'selected' : ''} ${file.status === 'error' ? 'has-error' : ''}`}
|
|
356
|
+
style={{ '--delay': `${Math.min(index, 18) * 20}ms` }}
|
|
357
|
+
onClick={onSelect}
|
|
358
|
+
onDoubleClick={onOpen}
|
|
359
|
+
tabIndex={0}
|
|
360
|
+
>
|
|
361
|
+
<div className="thumb">
|
|
362
|
+
{preview ? <img src={preview} alt="" loading="lazy" /> : <FilePlaceholder file={file} />}
|
|
363
|
+
{(file.status === 'processing' || file.status === 'queued') && <div className="processing"><LoaderCircle size={15} />Analyzing</div>}
|
|
364
|
+
<span className={`type-badge kind-${file.kind}`}>{file.extension.slice(1) || 'FILE'}</span>
|
|
365
|
+
</div>
|
|
366
|
+
<div className="file-copy">
|
|
367
|
+
<div className="file-name" title={file.name}>{file.name}</div>
|
|
368
|
+
<FileSummary file={file} compact={view === 'grid'} />
|
|
369
|
+
</div>
|
|
370
|
+
</article>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function FilePlaceholder({ file }) {
|
|
375
|
+
const Icon = file.kind === 'pdf' ? FileText : file.kind === 'image' ? FileImage : file.kind === 'cad' ? Boxes : File;
|
|
376
|
+
return <div className={`placeholder kind-${file.kind}`}><Icon size={38} strokeWidth={1.35} /><span>{file.kind === 'cad' ? 'CAD model' : file.kind === 'pdf' ? 'Drawing' : 'File'}</span></div>;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function FileSummary({ file, compact }) {
|
|
380
|
+
const size = file.analysis?.metadata?.bounding_box?.size;
|
|
381
|
+
if (size) return <span>{size.map(formatDimension).join(' × ')}</span>;
|
|
382
|
+
if (file.kind === 'pdf' && file.analysis?.metadata?.pages) return <span>{file.analysis.metadata.pages} {file.analysis.metadata.pages === 1 ? 'page' : 'pages'}</span>;
|
|
383
|
+
if (!compact) return <span>{formatBytes(file.size)} · {date.format(new Date(file.modifiedAt))}</span>;
|
|
384
|
+
return <span>{formatBytes(file.size)}</span>;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function Inspector({ file, onClose, onPreview, onOpen, onReveal, onCopy, onReanalyze }) {
|
|
388
|
+
if (!file) {
|
|
389
|
+
return (
|
|
390
|
+
<aside className="inspector empty-inspector">
|
|
391
|
+
<div className="inspector-empty-mark"><Info size={20} /></div>
|
|
392
|
+
<h2>File inspector</h2>
|
|
393
|
+
<p>Select a file to inspect its preview, dimensions, geometry, and document metadata.</p>
|
|
394
|
+
<div className="shortcut-list"><span>Navigate</span><kbd>Arrow keys</kbd><span>Open</span><kbd>O</kbd><span>Preview</span><kbd>Enter</kbd></div>
|
|
395
|
+
</aside>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
const metadata = file.analysis?.metadata;
|
|
399
|
+
const bbox = metadata?.bounding_box;
|
|
400
|
+
const geometry = metadata?.geometry;
|
|
401
|
+
return (
|
|
402
|
+
<aside className="inspector">
|
|
403
|
+
<div className="inspector-head">
|
|
404
|
+
<TypeIcon kind={file.kind} />
|
|
405
|
+
<div className="inspector-title"><strong>{file.name}</strong><span title={file.path}>{file.path}</span></div>
|
|
406
|
+
<button className="icon-button quiet" onClick={onClose} title="Close inspector"><X size={18} /></button>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<button className="inspector-preview" onClick={onPreview} disabled={!previewUrl(file)}>
|
|
410
|
+
{previewUrl(file) ? <img src={previewUrl(file)} alt={`Preview of ${file.name}`} /> : <FilePlaceholder file={file} />}
|
|
411
|
+
</button>
|
|
412
|
+
|
|
413
|
+
<div className="inspector-actions">
|
|
414
|
+
<button className="primary-action" onClick={onOpen}><ExternalLink size={16} />Open</button>
|
|
415
|
+
<button onClick={onReveal}><FolderOpen size={16} />Reveal</button>
|
|
416
|
+
<button onClick={onCopy} title="Copy project-relative path"><Copy size={16} /></button>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
{file.status === 'error' && <div className="error-note">{file.error}</div>}
|
|
420
|
+
{(file.status === 'queued' || file.status === 'processing') && <div className="analysis-note"><LoaderCircle size={16} />Extracting preview and metadata</div>}
|
|
421
|
+
|
|
422
|
+
<InspectorSection title="File">
|
|
423
|
+
<Property label="Type" value={(file.extension.slice(1) || 'file').toUpperCase()} />
|
|
424
|
+
<Property label="Size" value={formatBytes(file.size)} />
|
|
425
|
+
<Property label="Modified" value={date.format(new Date(file.modifiedAt))} />
|
|
426
|
+
<Property label="Status" value={capitalize(file.status)} />
|
|
427
|
+
</InspectorSection>
|
|
428
|
+
|
|
429
|
+
{bbox && (
|
|
430
|
+
<InspectorSection title="Bounding box">
|
|
431
|
+
<div className="dimension-strip">
|
|
432
|
+
{bbox.size.map((value, index) => <div key={index}><span>{['X', 'Y', 'Z'][index]}</span><strong>{formatDimension(value)}</strong></div>)}
|
|
433
|
+
</div>
|
|
434
|
+
<Vector label="Minimum" values={bbox.min} />
|
|
435
|
+
<Vector label="Maximum" values={bbox.max} />
|
|
436
|
+
</InspectorSection>
|
|
437
|
+
)}
|
|
438
|
+
|
|
439
|
+
{geometry && (
|
|
440
|
+
<InspectorSection title="Geometry">
|
|
441
|
+
<Property label="Meshes" value={number.format(geometry.mesh_count)} />
|
|
442
|
+
<Property label="Vertices" value={number.format(geometry.vertex_count)} />
|
|
443
|
+
<Property label="Triangles" value={number.format(geometry.triangle_count)} />
|
|
444
|
+
</InspectorSection>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{metadata?.materials && <Materials materials={metadata.materials} />}
|
|
448
|
+
{metadata?.pages && (
|
|
449
|
+
<InspectorSection title="Document">
|
|
450
|
+
<Property label="Pages" value={metadata.pages} />
|
|
451
|
+
{file.analysis?.text && <div className="text-preview">{file.analysis.text}</div>}
|
|
452
|
+
</InspectorSection>
|
|
453
|
+
)}
|
|
454
|
+
{file.analysis?.tree && <InspectorSection title="Model structure"><pre className="model-tree">{file.analysis.tree}</pre></InspectorSection>}
|
|
455
|
+
|
|
456
|
+
<div className="inspector-footer">
|
|
457
|
+
<button onClick={onReanalyze}><RefreshCw size={15} />Rebuild metadata and preview</button>
|
|
458
|
+
</div>
|
|
459
|
+
</aside>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function InspectorSection({ title, children }) {
|
|
464
|
+
return <section className="inspector-section"><h3>{title}</h3>{children}</section>;
|
|
465
|
+
}
|
|
466
|
+
function Property({ label, value }) {
|
|
467
|
+
return <div className="property"><span>{label}</span><strong>{value}</strong></div>;
|
|
468
|
+
}
|
|
469
|
+
function Vector({ label, values }) {
|
|
470
|
+
return <div className="vector"><span>{label}</span><code>[{values.map(formatDimension).join(', ')}]</code></div>;
|
|
471
|
+
}
|
|
472
|
+
function Materials({ materials }) {
|
|
473
|
+
if (!materials.colors?.length && !materials.names?.length) return null;
|
|
474
|
+
return (
|
|
475
|
+
<InspectorSection title="Materials and colors">
|
|
476
|
+
{materials.names?.map((name) => <div className="material-name" key={name}>{name}</div>)}
|
|
477
|
+
<div className="swatches">
|
|
478
|
+
{materials.colors?.map((color) => <div className="swatch" key={color.hex}><span style={{ background: color.hex }} /><code>{color.hex}</code><small>{color.faces} faces</small></div>)}
|
|
479
|
+
</div>
|
|
480
|
+
</InspectorSection>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function PreviewModal({ file, onClose }) {
|
|
485
|
+
return (
|
|
486
|
+
<div className="modal-backdrop" onClick={onClose} role="presentation">
|
|
487
|
+
<div className="preview-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={`Preview of ${file.name}`}>
|
|
488
|
+
<div className="modal-head"><div><strong>{file.name}</strong><span>{file.path}</span></div><button className="icon-button" onClick={onClose} title="Close preview"><X size={19} /></button></div>
|
|
489
|
+
<div className="modal-canvas"><img src={previewUrl(file)} alt={`Preview of ${file.name}`} /></div>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function TypeIcon({ kind }) {
|
|
496
|
+
const Icon = kind === 'pdf' ? FileText : kind === 'image' ? FileImage : kind === 'cad' ? Boxes : File;
|
|
497
|
+
return <span className={`type-icon kind-${kind}`}><Icon size={19} strokeWidth={1.7} /></span>;
|
|
498
|
+
}
|
|
499
|
+
function EmptyState({ query }) {
|
|
500
|
+
return <div className="empty-state"><Search size={24} /><h2>No matching files</h2><p>{query ? 'Search checks names, paths, and extracted PDF text across the entire project.' : 'This folder is empty.'}</p></div>;
|
|
501
|
+
}
|
|
502
|
+
function LoadingScreen() {
|
|
503
|
+
return <div className="loading-screen"><div className="brand-mark"><Box size={20} /></div><LoaderCircle size={18} /><span>Reading project files</span></div>;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function previewUrl(file) {
|
|
507
|
+
if (file.analysis?.previewPath) return `/api/preview?path=${encodeURIComponent(file.analysis.previewPath.split(/[\\/]/).at(-1))}`;
|
|
508
|
+
if (file.kind === 'image') return `/api/raw?path=${encodeURIComponent(file.path)}`;
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
function countNodeFiles(node) {
|
|
512
|
+
return node.children?.reduce((sum, child) => sum + (child.type === 'file' ? 1 : countNodeFiles(child)), 0) ?? 0;
|
|
513
|
+
}
|
|
514
|
+
function compareFiles(left, right, sortBy) {
|
|
515
|
+
if (sortBy === 'modified') return new Date(left.modifiedAt) - new Date(right.modifiedAt);
|
|
516
|
+
if (sortBy === 'size') return left.size - right.size;
|
|
517
|
+
if (sortBy === 'type') return left.extension.localeCompare(right.extension) || left.name.localeCompare(right.name, undefined, { numeric: true });
|
|
518
|
+
return left.name.localeCompare(right.name, undefined, { numeric: true });
|
|
519
|
+
}
|
|
520
|
+
function formatDimension(value) {
|
|
521
|
+
return number.format(Number(value));
|
|
522
|
+
}
|
|
523
|
+
function formatBytes(bytes) {
|
|
524
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
525
|
+
const units = ['KB', 'MB', 'GB'];
|
|
526
|
+
let value = bytes / 1024;
|
|
527
|
+
let unit = units[0];
|
|
528
|
+
for (let index = 1; value >= 1024 && index < units.length; index += 1) {
|
|
529
|
+
value /= 1024;
|
|
530
|
+
unit = units[index];
|
|
531
|
+
}
|
|
532
|
+
return `${number.format(value)} ${unit}`;
|
|
533
|
+
}
|
|
534
|
+
function capitalize(value) {
|
|
535
|
+
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : 'Unknown';
|
|
536
|
+
}
|
|
537
|
+
function densityFor(columns) {
|
|
538
|
+
if (columns >= 12) return 'micro';
|
|
539
|
+
if (columns >= 8) return 'compact';
|
|
540
|
+
return 'comfortable';
|
|
541
|
+
}
|
|
542
|
+
function useStoredNumber(key, fallback, min, max) {
|
|
543
|
+
const [value, setValue] = useState(() => {
|
|
544
|
+
const saved = Number(globalThis.localStorage?.getItem(key));
|
|
545
|
+
return Number.isInteger(saved) && saved >= min && saved <= max ? saved : fallback;
|
|
546
|
+
});
|
|
547
|
+
useEffect(() => globalThis.localStorage?.setItem(key, String(value)), [key, value]);
|
|
548
|
+
return [value, setValue];
|
|
549
|
+
}
|
|
550
|
+
async function loadProject(setter) {
|
|
551
|
+
const response = await fetch('/api/project');
|
|
552
|
+
setter(await response.json());
|
|
553
|
+
}
|
|
554
|
+
async function postFileAction(endpoint, filePath, extra = {}) {
|
|
555
|
+
const response = await fetch(endpoint, {
|
|
556
|
+
method: 'POST',
|
|
557
|
+
headers: { 'Content-Type': 'application/json' },
|
|
558
|
+
body: JSON.stringify({ path: filePath, ...extra }),
|
|
559
|
+
});
|
|
560
|
+
if (!response.ok) {
|
|
561
|
+
const payload = await response.json().catch(() => ({}));
|
|
562
|
+
throw new Error(payload.error ?? 'The file action failed');
|
|
563
|
+
}
|
|
564
|
+
return response.json();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
createRoot(document.getElementById('root')).render(<App />);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function resolveInside(rootPath, relativePath = '.') {
|
|
4
|
+
const root = path.resolve(rootPath);
|
|
5
|
+
const target = path.resolve(root, relativePath);
|
|
6
|
+
const relative = path.relative(root, target);
|
|
7
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
8
|
+
throw new Error('Path escapes the browsed project');
|
|
9
|
+
}
|
|
10
|
+
return target;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toProjectPath(rootPath, absolutePath) {
|
|
14
|
+
return path.relative(rootPath, absolutePath).split(path.sep).join('/');
|
|
15
|
+
}
|