@deckbuilderkit/view-explorer 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,117 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from 'react';
2
+ import { ErrorBanner, fileIconOf, Icon, Spinner, useStream } from '@deckbuilderkit/ui';
3
+ import type { Project, SearchHit } from '../client';
4
+ import { t } from '../i18n';
5
+
6
+ const MAX_HITS = 500;
7
+
8
+ export interface ProjectSearchProps {
9
+ project: Project;
10
+ /** Globs of entries search skips. */
11
+ exclude?: string[];
12
+ onOpenFile(project: string, path: string): void;
13
+ onClose(): void;
14
+ /** Focuses and selects the input each time it changes (and on mount). */
15
+ focusSignal: number;
16
+ }
17
+
18
+ /** Search box and results over one project's files (names, or names and content). */
19
+ export function ProjectSearch({ project, exclude, onOpenFile, onClose, focusSignal }: ProjectSearchProps) {
20
+ const input = useRef<HTMLInputElement>(null);
21
+ const [query, setQuery] = useState('');
22
+ const [content, setContent] = useState(true);
23
+ const [searched, setSearched] = useState(false);
24
+ const search = useStream<SearchHit>({ maxItems: MAX_HITS });
25
+
26
+ useEffect(() => {
27
+ input.current?.focus();
28
+ input.current?.select();
29
+ }, [focusSignal]);
30
+
31
+ const start = (q: string, withContent: boolean) => {
32
+ if (!q.trim()) return;
33
+ setSearched(true);
34
+ search.start('/api/fs/search', { root: project.id, path: '', q: q.trim(), content: withContent ? '1' : undefined, exclude: exclude?.length ? JSON.stringify(exclude) : undefined });
35
+ };
36
+
37
+ const close = () => {
38
+ search.cancel();
39
+ onClose();
40
+ };
41
+
42
+ const submit = (e: FormEvent) => {
43
+ e.preventDefault();
44
+ start(query, content);
45
+ };
46
+
47
+ const toggleContent = () => {
48
+ setContent(!content);
49
+ if (searched) start(query, !content);
50
+ };
51
+
52
+ return (
53
+ <div className="xp-search-panel">
54
+ <div className="xp-head">
55
+ <form className="xp-search" onSubmit={submit}>
56
+ <input
57
+ ref={input}
58
+ type="search"
59
+ value={query}
60
+ placeholder={t('explorer.search.placeholder', { name: project.name })}
61
+ aria-label={t('explorer.search.placeholder', { name: project.name })}
62
+ onChange={(e) => setQuery(e.target.value)}
63
+ onKeyDown={(e) => {
64
+ if (e.key !== 'Escape') return;
65
+ e.preventDefault();
66
+ if (search.running) search.cancel();
67
+ else close();
68
+ }}
69
+ />
70
+ </form>
71
+ <button type="button" className={`chip${content ? ' chip-on' : ''}`} onClick={toggleContent} aria-pressed={content}>
72
+ {t('explorer.search.content')}
73
+ </button>
74
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={close} title={t('explorer.search.close')} aria-label={t('explorer.search.close')}>
75
+ <Icon name="close" />
76
+ </button>
77
+ </div>
78
+ {searched && (
79
+ <div className="xp-results">
80
+ <div className="xp-status muted">
81
+ {search.running ? (
82
+ <>
83
+ <Spinner small /> {t('explorer.search.running')}
84
+ </>
85
+ ) : search.items.length === 0 && !search.error ? (
86
+ t('explorer.search.none')
87
+ ) : (
88
+ t('explorer.search.count', { count: search.items.length })
89
+ )}
90
+ {search.truncated && ` · ${t('explorer.search.truncated', { count: MAX_HITS })}`}
91
+ </div>
92
+ {search.error && <ErrorBanner error={search.error} compact />}
93
+ {search.items.map((hit, i) => (
94
+ <button
95
+ key={`${hit.path}:${hit.line ?? ''}:${i}`}
96
+ className="xp-hit"
97
+ disabled={hit.kind === 'dir'}
98
+ onClick={() => onOpenFile(project.id, hit.path)}
99
+ title={hit.path}
100
+ >
101
+ <span className={`tree-icon${fileIconOf(hit.path).tone ? ` tone-${fileIconOf(hit.path).tone}` : ''}`}>
102
+ <Icon name={hit.kind === 'dir' ? 'folder' : fileIconOf(hit.path).icon} size={14} />
103
+ </span>
104
+ <span className="xp-hit-text">
105
+ <span className="xp-hit-path">
106
+ {hit.path}
107
+ {hit.line ? `:${hit.line}` : ''}
108
+ </span>
109
+ {hit.text && <span className="xp-hit-line mono">{hit.text}</span>}
110
+ </span>
111
+ </button>
112
+ ))}
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }