repoimage 0.2.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/.claude/settings.local.json +8 -0
- package/AGENTS.md +28 -0
- package/PROJECT-AGENTS.md +55 -0
- package/README.md +153 -0
- package/TODO.md +132 -0
- package/client/index.html +12 -0
- package/client/package.json +23 -0
- package/client/src/App.tsx +599 -0
- package/client/src/components/FsBrowser.tsx +210 -0
- package/client/src/components/Settings.tsx +81 -0
- package/client/src/index.css +797 -0
- package/client/src/lib/api.ts +69 -0
- package/client/src/lib/collect.ts +204 -0
- package/client/src/lib/format.ts +96 -0
- package/client/src/lib/session.ts +58 -0
- package/client/src/main.tsx +10 -0
- package/client/src/vite-env.d.ts +1 -0
- package/client/tsconfig.json +18 -0
- package/client/vite.config.ts +27 -0
- package/docs/README.md +28 -0
- package/docs/api/overview.md +65 -0
- package/docs/api/scan.md +188 -0
- package/docs/architecture.md +155 -0
- package/docs/design/invariants.md +19 -0
- package/docs/design/role-system.md +50 -0
- package/docs/development/README.md +94 -0
- package/docs/features/README.md +21 -0
- package/docs/features/compression-score.md +75 -0
- package/docs/features/exclusions.md +63 -0
- package/docs/features/session.md +64 -0
- package/package.json +37 -0
- package/server/dist/cli.d.ts +3 -0
- package/server/dist/cli.d.ts.map +1 -0
- package/server/dist/cli.js +54 -0
- package/server/dist/cli.js.map +1 -0
- package/server/dist/fs-list.d.ts +3 -0
- package/server/dist/fs-list.d.ts.map +1 -0
- package/server/dist/fs-list.js +73 -0
- package/server/dist/fs-list.js.map +1 -0
- package/server/dist/paths.d.ts +3 -0
- package/server/dist/paths.d.ts.map +1 -0
- package/server/dist/paths.js +12 -0
- package/server/dist/paths.js.map +1 -0
- package/server/dist/scan.d.ts +16 -0
- package/server/dist/scan.d.ts.map +1 -0
- package/server/dist/scan.js +158 -0
- package/server/dist/scan.js.map +1 -0
- package/server/dist/server.d.ts +6 -0
- package/server/dist/server.d.ts.map +1 -0
- package/server/dist/server.js +313 -0
- package/server/dist/server.js.map +1 -0
- package/server/package.json +22 -0
- package/server/src/cli.ts +63 -0
- package/server/src/fs-list.ts +70 -0
- package/server/src/paths.ts +6 -0
- package/server/src/scan.ts +203 -0
- package/server/src/server.ts +356 -0
- package/server/tsconfig.json +9 -0
- package/shared/package.json +10 -0
- package/shared/src/constants.ts +37 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/role-guess.ts +103 -0
- package/shared/src/schema.ts +18 -0
- package/shared/src/types.ts +36 -0
- package/shared/tsconfig.json +9 -0
- package/test/cli.test.js +56 -0
- package/test/fs-list.test.js +39 -0
- package/test/role-guess.test.js +50 -0
- package/test/scan.test.js +150 -0
- package/test/server.test.js +308 -0
- package/tsconfig.base.json +14 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { FsListEntry } from '@repoimage/shared';
|
|
3
|
+
import { API_ORIGIN, fetchApiJson } from '../lib/api';
|
|
4
|
+
|
|
5
|
+
const LS_FS_SHOW_HIDDEN = 'repoimage-fs-show-hidden';
|
|
6
|
+
|
|
7
|
+
function fsEntryBaseName(ent: FsListEntry): string {
|
|
8
|
+
if (ent.name.length) return ent.name;
|
|
9
|
+
const p = ent.path.replace(/\\/g, '/');
|
|
10
|
+
const i = p.lastIndexOf('/');
|
|
11
|
+
return i === -1 ? p : p.slice(i + 1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fsParentDir(p: string): string {
|
|
15
|
+
const n = (p || '/').replace(/\/+$/, '') || '/';
|
|
16
|
+
if (n === '/') return '/';
|
|
17
|
+
const i = n.lastIndexOf('/');
|
|
18
|
+
if (i <= 0) return '/';
|
|
19
|
+
return n.slice(0, i) || '/';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FsBrowserProps {
|
|
23
|
+
open: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onUseFolder: (path: string) => void;
|
|
26
|
+
onSystemPick: () => void;
|
|
27
|
+
fileProtocolError: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function FsBrowser({
|
|
31
|
+
open,
|
|
32
|
+
onClose,
|
|
33
|
+
onUseFolder,
|
|
34
|
+
onSystemPick,
|
|
35
|
+
fileProtocolError
|
|
36
|
+
}: FsBrowserProps) {
|
|
37
|
+
const [dir, setDir] = useState('');
|
|
38
|
+
const [pathInput, setPathInput] = useState('');
|
|
39
|
+
const [entriesRaw, setEntriesRaw] = useState<FsListEntry[]>([]);
|
|
40
|
+
const [error, setError] = useState('');
|
|
41
|
+
const [showHidden, setShowHidden] = useState(() => {
|
|
42
|
+
try {
|
|
43
|
+
const stored = localStorage.getItem(LS_FS_SHOW_HIDDEN);
|
|
44
|
+
if (stored === '1') return true;
|
|
45
|
+
if (stored === '0') return false;
|
|
46
|
+
} catch {
|
|
47
|
+
/* ignore */
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const loadDir = useCallback(async (dirPath: string) => {
|
|
53
|
+
setError('');
|
|
54
|
+
setEntriesRaw([]);
|
|
55
|
+
try {
|
|
56
|
+
const q = encodeURIComponent(dirPath);
|
|
57
|
+
const data = await fetchApiJson(`/api/fs/list?path=${q}`);
|
|
58
|
+
const resolved = data.path as string;
|
|
59
|
+
setDir(resolved);
|
|
60
|
+
setPathInput(resolved);
|
|
61
|
+
setEntriesRaw((data.entries as FsListEntry[]) || []);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
setEntriesRaw([]);
|
|
64
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!open) return;
|
|
70
|
+
if (API_ORIGIN === null) {
|
|
71
|
+
setError(fileProtocolError || 'API unavailable');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
const home = await fetchApiJson('/api/fs/home');
|
|
77
|
+
await loadDir(home.path as string);
|
|
78
|
+
} catch {
|
|
79
|
+
await loadDir('/');
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
}, [open, loadDir, fileProtocolError]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!open) return;
|
|
86
|
+
const onKey = (e: KeyboardEvent) => {
|
|
87
|
+
if (e.key === 'Escape') onClose();
|
|
88
|
+
};
|
|
89
|
+
document.addEventListener('keydown', onKey);
|
|
90
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
91
|
+
}, [open, onClose]);
|
|
92
|
+
|
|
93
|
+
if (!open) return null;
|
|
94
|
+
|
|
95
|
+
const filtered = showHidden
|
|
96
|
+
? entriesRaw
|
|
97
|
+
: entriesRaw.filter(e => !fsEntryBaseName(e).startsWith('.'));
|
|
98
|
+
|
|
99
|
+
const dirs = filtered.filter(e => e.type === 'directory');
|
|
100
|
+
const rest = filtered.filter(e => e.type !== 'directory');
|
|
101
|
+
const ordered = [...dirs, ...rest];
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="fs-browser" aria-hidden={false}>
|
|
105
|
+
<div className="fs-browser__backdrop" onClick={onClose} />
|
|
106
|
+
<div
|
|
107
|
+
className="fs-browser__panel"
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
aria-labelledby="fs-browser-title"
|
|
111
|
+
>
|
|
112
|
+
<div className="fs-browser__head">
|
|
113
|
+
<h2 id="fs-browser-title" className="fs-browser__title">
|
|
114
|
+
Choose folder
|
|
115
|
+
</h2>
|
|
116
|
+
<button type="button" className="fs-browser__close" aria-label="Close" onClick={onClose}>
|
|
117
|
+
×
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="fs-browser__path-row">
|
|
121
|
+
<input
|
|
122
|
+
type="text"
|
|
123
|
+
className="fs-browser__path-input controls__text-input"
|
|
124
|
+
spellCheck={false}
|
|
125
|
+
autoComplete="off"
|
|
126
|
+
value={pathInput}
|
|
127
|
+
onChange={e => setPathInput(e.target.value)}
|
|
128
|
+
onKeyDown={e => {
|
|
129
|
+
if (e.key === 'Enter' && pathInput.trim()) void loadDir(pathInput.trim());
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
className="btn btn--secondary"
|
|
135
|
+
onClick={() => pathInput.trim() && void loadDir(pathInput.trim())}
|
|
136
|
+
>
|
|
137
|
+
Go
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className="btn btn--secondary"
|
|
142
|
+
onClick={() => void loadDir(fsParentDir(dir || pathInput.trim() || '/'))}
|
|
143
|
+
>
|
|
144
|
+
Up
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="fs-browser__opts">
|
|
148
|
+
<label className="fs-browser__hidden-label">
|
|
149
|
+
<input
|
|
150
|
+
type="checkbox"
|
|
151
|
+
checked={showHidden}
|
|
152
|
+
onChange={e => {
|
|
153
|
+
setShowHidden(e.target.checked);
|
|
154
|
+
try {
|
|
155
|
+
localStorage.setItem(LS_FS_SHOW_HIDDEN, e.target.checked ? '1' : '0');
|
|
156
|
+
} catch {
|
|
157
|
+
/* ignore */
|
|
158
|
+
}
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
Show hidden files
|
|
162
|
+
</label>
|
|
163
|
+
</div>
|
|
164
|
+
{error ? (
|
|
165
|
+
<p className="fs-browser__error" role="alert">
|
|
166
|
+
{error}
|
|
167
|
+
</p>
|
|
168
|
+
) : null}
|
|
169
|
+
<ul className="fs-browser__list">
|
|
170
|
+
{ordered.length === 0 ? (
|
|
171
|
+
<li className="fs-browser__empty">(empty)</li>
|
|
172
|
+
) : (
|
|
173
|
+
ordered.map(ent => (
|
|
174
|
+
<li
|
|
175
|
+
key={ent.path}
|
|
176
|
+
data-path={ent.path}
|
|
177
|
+
data-type={ent.type}
|
|
178
|
+
onClick={() => {
|
|
179
|
+
if (ent.type === 'directory') void loadDir(ent.path);
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{ent.type === 'directory' ? `${fsEntryBaseName(ent)}/` : fsEntryBaseName(ent) || '—'}
|
|
183
|
+
</li>
|
|
184
|
+
))
|
|
185
|
+
)}
|
|
186
|
+
</ul>
|
|
187
|
+
<div className="fs-browser__actions">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className="btn btn--primary"
|
|
191
|
+
onClick={() => {
|
|
192
|
+
const p = (pathInput || dir).trim();
|
|
193
|
+
if (p) onUseFolder(p);
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
Use this folder
|
|
197
|
+
</button>
|
|
198
|
+
<button type="button" className="btn btn--secondary" onClick={onClose}>
|
|
199
|
+
Cancel
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
<p className="fs-browser__alt">
|
|
203
|
+
<button type="button" className="fs-browser__linkbtn" onClick={onSystemPick}>
|
|
204
|
+
Use system folder dialog instead…
|
|
205
|
+
</button>
|
|
206
|
+
</p>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SettingsProps {
|
|
4
|
+
excludeCommonFolders: boolean;
|
|
5
|
+
onExcludeChange: (value: boolean) => void;
|
|
6
|
+
onClearData: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Settings({ excludeCommonFolders, onExcludeChange, onClearData }: SettingsProps) {
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
function handleClickOutside(event: MouseEvent) {
|
|
16
|
+
if (
|
|
17
|
+
dropdownRef.current &&
|
|
18
|
+
!dropdownRef.current.contains(event.target as Node) &&
|
|
19
|
+
buttonRef.current &&
|
|
20
|
+
!buttonRef.current.contains(event.target as Node)
|
|
21
|
+
) {
|
|
22
|
+
setOpen(false);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (open) {
|
|
27
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
28
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
29
|
+
}
|
|
30
|
+
}, [open, setOpen]);
|
|
31
|
+
|
|
32
|
+
const handleClear = useCallback(() => {
|
|
33
|
+
if (confirm('Clear all session data (working directory, preferences)? This cannot be undone.')) {
|
|
34
|
+
onClearData();
|
|
35
|
+
setOpen(false);
|
|
36
|
+
}
|
|
37
|
+
}, [onClearData, setOpen]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="settings-container">
|
|
41
|
+
<button
|
|
42
|
+
ref={buttonRef}
|
|
43
|
+
type="button"
|
|
44
|
+
className="settings-button"
|
|
45
|
+
onClick={() => setOpen(!open)}
|
|
46
|
+
aria-label="Settings"
|
|
47
|
+
aria-expanded={open}
|
|
48
|
+
>
|
|
49
|
+
⚙️
|
|
50
|
+
</button>
|
|
51
|
+
|
|
52
|
+
{open && (
|
|
53
|
+
<div ref={dropdownRef} className="settings-dropdown">
|
|
54
|
+
<div className="settings-item">
|
|
55
|
+
<label className="settings-label">
|
|
56
|
+
<input
|
|
57
|
+
type="checkbox"
|
|
58
|
+
className="settings-checkbox"
|
|
59
|
+
checked={excludeCommonFolders}
|
|
60
|
+
onChange={e => onExcludeChange(e.target.checked)}
|
|
61
|
+
/>
|
|
62
|
+
<span>Exclude common folders</span>
|
|
63
|
+
</label>
|
|
64
|
+
<p className="settings-hint">Skip node_modules, dist, .git, etc.</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<hr className="settings-divider" />
|
|
68
|
+
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className="settings-action"
|
|
72
|
+
onClick={handleClear}
|
|
73
|
+
>
|
|
74
|
+
Clear all data
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|