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.
Files changed (71) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/AGENTS.md +28 -0
  3. package/PROJECT-AGENTS.md +55 -0
  4. package/README.md +153 -0
  5. package/TODO.md +132 -0
  6. package/client/index.html +12 -0
  7. package/client/package.json +23 -0
  8. package/client/src/App.tsx +599 -0
  9. package/client/src/components/FsBrowser.tsx +210 -0
  10. package/client/src/components/Settings.tsx +81 -0
  11. package/client/src/index.css +797 -0
  12. package/client/src/lib/api.ts +69 -0
  13. package/client/src/lib/collect.ts +204 -0
  14. package/client/src/lib/format.ts +96 -0
  15. package/client/src/lib/session.ts +58 -0
  16. package/client/src/main.tsx +10 -0
  17. package/client/src/vite-env.d.ts +1 -0
  18. package/client/tsconfig.json +18 -0
  19. package/client/vite.config.ts +27 -0
  20. package/docs/README.md +28 -0
  21. package/docs/api/overview.md +65 -0
  22. package/docs/api/scan.md +188 -0
  23. package/docs/architecture.md +155 -0
  24. package/docs/design/invariants.md +19 -0
  25. package/docs/design/role-system.md +50 -0
  26. package/docs/development/README.md +94 -0
  27. package/docs/features/README.md +21 -0
  28. package/docs/features/compression-score.md +75 -0
  29. package/docs/features/exclusions.md +63 -0
  30. package/docs/features/session.md +64 -0
  31. package/package.json +37 -0
  32. package/server/dist/cli.d.ts +3 -0
  33. package/server/dist/cli.d.ts.map +1 -0
  34. package/server/dist/cli.js +54 -0
  35. package/server/dist/cli.js.map +1 -0
  36. package/server/dist/fs-list.d.ts +3 -0
  37. package/server/dist/fs-list.d.ts.map +1 -0
  38. package/server/dist/fs-list.js +73 -0
  39. package/server/dist/fs-list.js.map +1 -0
  40. package/server/dist/paths.d.ts +3 -0
  41. package/server/dist/paths.d.ts.map +1 -0
  42. package/server/dist/paths.js +12 -0
  43. package/server/dist/paths.js.map +1 -0
  44. package/server/dist/scan.d.ts +16 -0
  45. package/server/dist/scan.d.ts.map +1 -0
  46. package/server/dist/scan.js +158 -0
  47. package/server/dist/scan.js.map +1 -0
  48. package/server/dist/server.d.ts +6 -0
  49. package/server/dist/server.d.ts.map +1 -0
  50. package/server/dist/server.js +313 -0
  51. package/server/dist/server.js.map +1 -0
  52. package/server/package.json +22 -0
  53. package/server/src/cli.ts +63 -0
  54. package/server/src/fs-list.ts +70 -0
  55. package/server/src/paths.ts +6 -0
  56. package/server/src/scan.ts +203 -0
  57. package/server/src/server.ts +356 -0
  58. package/server/tsconfig.json +9 -0
  59. package/shared/package.json +10 -0
  60. package/shared/src/constants.ts +37 -0
  61. package/shared/src/index.ts +4 -0
  62. package/shared/src/role-guess.ts +103 -0
  63. package/shared/src/schema.ts +18 -0
  64. package/shared/src/types.ts +36 -0
  65. package/shared/tsconfig.json +9 -0
  66. package/test/cli.test.js +56 -0
  67. package/test/fs-list.test.js +39 -0
  68. package/test/role-guess.test.js +50 -0
  69. package/test/scan.test.js +150 -0
  70. package/test/server.test.js +308 -0
  71. 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
+