critique 0.0.3 → 0.0.5
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/AGENTS.md +11 -0
- package/CHANGELOG.md +16 -0
- package/bun.lock +3 -0
- package/package.json +4 -3
- package/src/cli.tsx +162 -56
- package/src/descendants.tsx +134 -0
- package/src/dropdown.tsx +423 -0
- package/src/theme.tsx +29 -0
package/AGENTS.md
CHANGED
|
@@ -10,6 +10,8 @@ ALWAYS!
|
|
|
10
10
|
|
|
11
11
|
NEVER run bun run index.tsx. You cannot directly run the tui app. it will hang. instead ask me to do so.
|
|
12
12
|
|
|
13
|
+
NEVER use require. just import at the top of the file with esm
|
|
14
|
+
|
|
13
15
|
use bun add to install packages instead of npm
|
|
14
16
|
|
|
15
17
|
## React
|
|
@@ -62,3 +64,12 @@ you can read more examples of opentui react code using gitchamber by listing and
|
|
|
62
64
|
after any meaningful change update CHANGELOG.md with the version number and the list of changes made. in concise bullet points
|
|
63
65
|
|
|
64
66
|
before updating the changelog bump the package.json version field first. NEVER do major bumps. NEVER publish yourself
|
|
67
|
+
|
|
68
|
+
NEVER update existing changelog bullet points for previous version unless you added those bullet points yourself recently and the change is of the same version as it is now.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## zustand
|
|
72
|
+
|
|
73
|
+
- minimize number of props. do not use props if you can use zustand state instead. the app has global zustand state that lets you get a piece of state down from the component tree by using something like `useStore(x => x.something)` or `useLoaderData<typeof loader>()` or even useRouteLoaderData if you are deep in the react component tree
|
|
74
|
+
|
|
75
|
+
- do not consider local state truthful when interacting with server. when interacting with the server with rpc or api calls never use state from the render function as input for the api call. this state can easily become stale or not get updated in the closure context. instead prefer using zustand `useStore.getState().stateValue`. notice that useLoaderData or useParams should be fine in this case.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
# 0.0.5
|
|
2
|
+
|
|
3
|
+
- Fix patch application to use merge-base for correct ahead-only commits
|
|
4
|
+
- Use `git checkout HEAD` for reliable file restoration on deselect
|
|
5
|
+
- Improve error messages with full stderr output
|
|
6
|
+
- Add `execSyncWithError` wrapper for better error handling
|
|
7
|
+
- Support multi-select with array-based selected values in Dropdown
|
|
8
|
+
|
|
9
|
+
# 0.0.4
|
|
10
|
+
|
|
11
|
+
- Add `pick` command to selectively apply files from another branch using interactive UI
|
|
12
|
+
- Use Dropdown component with search and keyboard navigation
|
|
13
|
+
- Apply/restore patches on select/deselect with live preview
|
|
14
|
+
- Support conflict detection and 3-way merge
|
|
15
|
+
- Show error messages in UI
|
|
16
|
+
|
|
1
17
|
# 0.0.3
|
|
2
18
|
|
|
3
19
|
- Add `pick` command to selectively apply files from another branch
|
package/bun.lock
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"react": "^19.2.0",
|
|
15
15
|
"react-error-boundary": "^6.0.0",
|
|
16
16
|
"shiki": "^3.13.0",
|
|
17
|
+
"zustand": "^5.0.8",
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@types/bun": "1.2.23",
|
|
@@ -320,6 +321,8 @@
|
|
|
320
321
|
|
|
321
322
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
322
323
|
|
|
324
|
+
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
|
|
325
|
+
|
|
323
326
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
|
324
327
|
|
|
325
328
|
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
|
package/package.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"name": "critique",
|
|
3
3
|
"module": "src/diff.tsx",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.5",
|
|
6
6
|
"private": false,
|
|
7
7
|
"bin": "./src/cli.tsx",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"cli": "bun
|
|
9
|
+
"cli": "bun src/cli.tsx",
|
|
10
10
|
"prepublishonly": "tsc",
|
|
11
11
|
"example": "bun --watch src/example.tsx"
|
|
12
12
|
},
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"diff": "^8.0.2",
|
|
25
25
|
"react": "^19.2.0",
|
|
26
26
|
"react-error-boundary": "^6.0.0",
|
|
27
|
-
"shiki": "^3.13.0"
|
|
27
|
+
"shiki": "^3.13.0",
|
|
28
|
+
"zustand": "^5.0.8"
|
|
28
29
|
}
|
|
29
30
|
}
|
package/src/cli.tsx
CHANGED
|
@@ -15,9 +15,24 @@ import fs from "fs";
|
|
|
15
15
|
import { tmpdir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import * as p from "@clack/prompts";
|
|
18
|
+
import { create } from "zustand";
|
|
19
|
+
import Dropdown from "./dropdown.tsx";
|
|
18
20
|
|
|
19
21
|
const execAsync = promisify(exec);
|
|
20
22
|
|
|
23
|
+
function execSyncWithError(
|
|
24
|
+
command: string,
|
|
25
|
+
options?: any,
|
|
26
|
+
): { data?: any; error?: string } {
|
|
27
|
+
try {
|
|
28
|
+
const data = execSync(command, options);
|
|
29
|
+
return { data };
|
|
30
|
+
} catch (error: any) {
|
|
31
|
+
const stderr = error.stderr?.toString() || error.message || String(error);
|
|
32
|
+
return { error: stderr };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
const cli = cac("critique");
|
|
22
37
|
|
|
23
38
|
class ScrollAcceleration {
|
|
@@ -223,10 +238,7 @@ cli
|
|
|
223
238
|
});
|
|
224
239
|
|
|
225
240
|
cli
|
|
226
|
-
.command(
|
|
227
|
-
"pick <branch>",
|
|
228
|
-
"Pick files from another branch to apply to HEAD (experimental)",
|
|
229
|
-
)
|
|
241
|
+
.command("pick <branch>", "Pick files from another branch to apply to HEAD")
|
|
230
242
|
.action(async (branch: string) => {
|
|
231
243
|
try {
|
|
232
244
|
const { stdout: currentBranch } = await execAsync(
|
|
@@ -235,7 +247,7 @@ cli
|
|
|
235
247
|
const current = currentBranch.trim();
|
|
236
248
|
|
|
237
249
|
if (current === branch) {
|
|
238
|
-
|
|
250
|
+
console.error("Cannot pick from the same branch");
|
|
239
251
|
process.exit(1);
|
|
240
252
|
}
|
|
241
253
|
|
|
@@ -245,12 +257,12 @@ cli
|
|
|
245
257
|
).catch(() => ({ stdout: "" }));
|
|
246
258
|
|
|
247
259
|
if (!branchExists.trim()) {
|
|
248
|
-
|
|
260
|
+
console.error(`Branch "${branch}" does not exist`);
|
|
249
261
|
process.exit(1);
|
|
250
262
|
}
|
|
251
263
|
|
|
252
264
|
const { stdout: diffOutput } = await execAsync(
|
|
253
|
-
`git diff --name-only HEAD
|
|
265
|
+
`git diff --name-only HEAD...${branch}`,
|
|
254
266
|
{ encoding: "utf-8" },
|
|
255
267
|
);
|
|
256
268
|
|
|
@@ -260,64 +272,158 @@ cli
|
|
|
260
272
|
.filter((f) => f);
|
|
261
273
|
|
|
262
274
|
if (files.length === 0) {
|
|
263
|
-
|
|
264
|
-
process.exit(0);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const selectedFiles = await p.autocompleteMultiselect({
|
|
268
|
-
message: `Select files to pick from "${branch}":`,
|
|
269
|
-
options: files.map((file) => ({
|
|
270
|
-
value: file,
|
|
271
|
-
label: file,
|
|
272
|
-
})),
|
|
273
|
-
required: false,
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
if (p.isCancel(selectedFiles)) {
|
|
277
|
-
p.cancel("Operation cancelled.");
|
|
275
|
+
console.log("No differences found between branches");
|
|
278
276
|
process.exit(0);
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
interface PickState {
|
|
280
|
+
selectedFiles: Set<string>;
|
|
281
|
+
appliedFiles: Map<string, boolean>; // Track which files have patches applied
|
|
282
|
+
message: string;
|
|
283
|
+
messageType: "info" | "error" | "success" | "";
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
fs.writeFileSync(patchFile, patchData);
|
|
286
|
+
const usePickStore = create<PickState>(() => ({
|
|
287
|
+
selectedFiles: new Set(),
|
|
288
|
+
appliedFiles: new Map(),
|
|
289
|
+
message: "",
|
|
290
|
+
messageType: "",
|
|
291
|
+
}));
|
|
293
292
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
execSync(`git apply "${patchFile}"`, { stdio: "inherit" });
|
|
293
|
+
interface PickAppProps {
|
|
294
|
+
files: string[];
|
|
295
|
+
branch: string;
|
|
298
296
|
}
|
|
299
297
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
298
|
+
function PickApp({ files, branch }: PickAppProps) {
|
|
299
|
+
const selectedFiles = usePickStore((s) => s.selectedFiles);
|
|
300
|
+
const message = usePickStore((s) => s.message);
|
|
301
|
+
const messageType = usePickStore((s) => s.messageType);
|
|
302
|
+
|
|
303
|
+
const handleChange = async (value: string) => {
|
|
304
|
+
const isSelected = selectedFiles.has(value);
|
|
305
|
+
|
|
306
|
+
if (isSelected) {
|
|
307
|
+
const { error } = execSyncWithError(
|
|
308
|
+
`git checkout HEAD -- "${value}"`,
|
|
309
|
+
{ stdio: "pipe" },
|
|
310
|
+
);
|
|
311
|
+
if (error) {
|
|
312
|
+
usePickStore.setState({
|
|
313
|
+
message: `Failed to restore ${value}: ${error}`,
|
|
314
|
+
messageType: "error",
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
usePickStore.setState((state) => ({
|
|
320
|
+
selectedFiles: new Set(
|
|
321
|
+
Array.from(state.selectedFiles).filter((f) => f !== value),
|
|
322
|
+
),
|
|
323
|
+
appliedFiles: new Map(
|
|
324
|
+
Array.from(state.appliedFiles).filter(([k]) => k !== value),
|
|
325
|
+
),
|
|
326
|
+
}));
|
|
327
|
+
} else {
|
|
328
|
+
const { stdout: mergeBase } = await execAsync(
|
|
329
|
+
`git merge-base HEAD ${branch}`,
|
|
330
|
+
{ encoding: "utf-8" },
|
|
331
|
+
);
|
|
332
|
+
const base = mergeBase.trim();
|
|
333
|
+
|
|
334
|
+
const { stdout: patchData } = await execAsync(
|
|
335
|
+
`git diff ${base} ${branch} -- ${value}`,
|
|
336
|
+
{ encoding: "utf-8" },
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const patchFile = join(
|
|
340
|
+
tmpdir(),
|
|
341
|
+
`critique-pick-${Date.now()}.patch`,
|
|
342
|
+
);
|
|
343
|
+
fs.writeFileSync(patchFile, patchData);
|
|
344
|
+
|
|
345
|
+
const result1 = execSyncWithError(
|
|
346
|
+
`git apply --3way "${patchFile}"`,
|
|
347
|
+
{
|
|
348
|
+
stdio: "pipe",
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (result1.error) {
|
|
353
|
+
const result2 = execSyncWithError(`git apply "${patchFile}"`, {
|
|
354
|
+
stdio: "pipe",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (result2.error) {
|
|
358
|
+
usePickStore.setState({
|
|
359
|
+
message: `Failed to apply ${value}: ${result2.error}`,
|
|
360
|
+
messageType: "error",
|
|
361
|
+
});
|
|
362
|
+
fs.unlinkSync(patchFile);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fs.unlinkSync(patchFile);
|
|
368
|
+
|
|
369
|
+
usePickStore.setState((state) => ({
|
|
370
|
+
selectedFiles: new Set([...state.selectedFiles, value]),
|
|
371
|
+
appliedFiles: new Map([...state.appliedFiles, [value, true]]),
|
|
372
|
+
message: "",
|
|
373
|
+
messageType: "",
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
<box style={{ padding: 1, flexDirection: "column" }}>
|
|
380
|
+
<Dropdown
|
|
381
|
+
tooltip={`Pick files from "${branch}"`}
|
|
382
|
+
onChange={handleChange}
|
|
383
|
+
selectedValues={Array.from(selectedFiles)}
|
|
384
|
+
placeholder="Search files..."
|
|
385
|
+
>
|
|
386
|
+
<Dropdown.Section>
|
|
387
|
+
{files.map((file) => (
|
|
388
|
+
<Dropdown.Item
|
|
389
|
+
key={file}
|
|
390
|
+
value={file}
|
|
391
|
+
title={file}
|
|
392
|
+
keywords={file.split("/")}
|
|
393
|
+
/>
|
|
394
|
+
))}
|
|
395
|
+
</Dropdown.Section>
|
|
396
|
+
</Dropdown>
|
|
397
|
+
{message && (
|
|
398
|
+
<box
|
|
399
|
+
style={{
|
|
400
|
+
paddingLeft: 2,
|
|
401
|
+
paddingRight: 2,
|
|
402
|
+
paddingTop: 1,
|
|
403
|
+
paddingBottom: 1,
|
|
404
|
+
marginTop: 1,
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
<text
|
|
408
|
+
fg={
|
|
409
|
+
messageType === "error"
|
|
410
|
+
? "#ff6b6b"
|
|
411
|
+
: messageType === "success"
|
|
412
|
+
? "#51cf66"
|
|
413
|
+
: "#ffffff"
|
|
414
|
+
}
|
|
415
|
+
>
|
|
416
|
+
{message}
|
|
417
|
+
</text>
|
|
418
|
+
</box>
|
|
419
|
+
)}
|
|
420
|
+
</box>
|
|
421
|
+
);
|
|
317
422
|
}
|
|
318
|
-
|
|
423
|
+
|
|
424
|
+
await render(<PickApp files={files} branch={branch} />);
|
|
319
425
|
} catch (error) {
|
|
320
|
-
|
|
426
|
+
console.error(
|
|
321
427
|
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
322
428
|
);
|
|
323
429
|
process.exit(1);
|
|
@@ -326,5 +432,5 @@ cli
|
|
|
326
432
|
|
|
327
433
|
cli.help();
|
|
328
434
|
cli.version("1.0.0");
|
|
329
|
-
|
|
435
|
+
// comment
|
|
330
436
|
cli.parse();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// inspired by https://github.com/pacocoursey/use-descendants
|
|
2
|
+
//
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
type DescendantMap<T> = { [id: string]: { index: number; props?: T } };
|
|
6
|
+
|
|
7
|
+
export interface DescendantContextType<T> {
|
|
8
|
+
getIndexForId: (id: string, props?: T) => number;
|
|
9
|
+
// IMPORTANT! map is not reactive, it cannot be used in render, only in useEffect or useLayoutEffect or other event handlers like useKeyboard
|
|
10
|
+
map: React.RefObject<DescendantMap<T>>;
|
|
11
|
+
reset: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const randomId = () => Math.random().toString(36).slice(2, 11);
|
|
15
|
+
|
|
16
|
+
export function createDescendants<T = any>() {
|
|
17
|
+
const DescendantContext = React.createContext<
|
|
18
|
+
DescendantContextType<T> | undefined
|
|
19
|
+
>(undefined);
|
|
20
|
+
|
|
21
|
+
function DescendantsProvider(props: {
|
|
22
|
+
value: DescendantContextType<T>;
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
// On every re-render of children, reset the count
|
|
26
|
+
props.value.reset();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<DescendantContext.Provider value={props.value}>
|
|
30
|
+
{props.children}
|
|
31
|
+
</DescendantContext.Provider>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const useDescendants = (): DescendantContextType<T> => {
|
|
36
|
+
const indexCounter = React.useRef<number>(0);
|
|
37
|
+
const map = React.useRef<DescendantMap<T>>({});
|
|
38
|
+
|
|
39
|
+
const reset = () => {
|
|
40
|
+
indexCounter.current = 0;
|
|
41
|
+
map.current = {};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getIndexForId = (id: string, props?: T) => {
|
|
45
|
+
if (!map.current[id])
|
|
46
|
+
map.current[id] = {
|
|
47
|
+
index: indexCounter.current++,
|
|
48
|
+
};
|
|
49
|
+
map.current[id].props = props;
|
|
50
|
+
return map.current[id].index;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// React.useEffect(() => {
|
|
54
|
+
// return () => {
|
|
55
|
+
// reset()
|
|
56
|
+
// }
|
|
57
|
+
// }, [])
|
|
58
|
+
|
|
59
|
+
// Do NOT memoize context value, so that we bypass React.memo on any children
|
|
60
|
+
// We NEED them to re-render, in case stable children were re-ordered
|
|
61
|
+
// (this creates a new object every render, so children reading the context MUST re-render)
|
|
62
|
+
return { getIndexForId, map, reset };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return index of the current item within its parent's list
|
|
67
|
+
*/
|
|
68
|
+
function useDescendant(props?: T) {
|
|
69
|
+
const context = React.useContext(DescendantContext);
|
|
70
|
+
const [descendantId] = React.useState<string>(() => randomId());
|
|
71
|
+
const [index, setIndex] = React.useState<number>(-1);
|
|
72
|
+
|
|
73
|
+
React.useLayoutEffect(() => {
|
|
74
|
+
// Do this inside of useLayoutEffect, it's only
|
|
75
|
+
// called for the "real render" in React strict mode
|
|
76
|
+
setIndex(context?.getIndexForId(descendantId, props) ?? -1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { descendantId, index };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { DescendantsProvider, useDescendants, useDescendant };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// EXAMPLE
|
|
86
|
+
const { DescendantsProvider, useDescendants, useDescendant } =
|
|
87
|
+
createDescendants<{
|
|
88
|
+
title?: string;
|
|
89
|
+
}>();
|
|
90
|
+
|
|
91
|
+
const FilteredIndexesContext = React.createContext<number[]>([]);
|
|
92
|
+
|
|
93
|
+
const MenuExample = () => {
|
|
94
|
+
const context = useDescendants();
|
|
95
|
+
const [search, setSearchRaw] = React.useState("");
|
|
96
|
+
const [filteredIndexes, setFilteredIndexes] = React.useState<number[]>([]);
|
|
97
|
+
|
|
98
|
+
// Filtering logic is now in this wrapper
|
|
99
|
+
const setSearch = (value: string) => {
|
|
100
|
+
setSearchRaw(value);
|
|
101
|
+
const items = Object.entries(context.map.current);
|
|
102
|
+
const filtered = items
|
|
103
|
+
.filter(([, item]) =>
|
|
104
|
+
item.props?.title?.toLowerCase().includes(value.toLowerCase()),
|
|
105
|
+
)
|
|
106
|
+
.map(([, item]) => item.index);
|
|
107
|
+
setFilteredIndexes(filtered);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<DescendantsProvider value={context}>
|
|
112
|
+
<FilteredIndexesContext.Provider value={filteredIndexes}>
|
|
113
|
+
<box>
|
|
114
|
+
<input value={search} onInput={setSearch} placeholder="Search..." />
|
|
115
|
+
<Item title="First Item" />
|
|
116
|
+
<Item title="Second Item" />
|
|
117
|
+
<Item title="Third Item" />
|
|
118
|
+
</box>
|
|
119
|
+
</FilteredIndexesContext.Provider>
|
|
120
|
+
</DescendantsProvider>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const Item = ({ title }: { title: string }) => {
|
|
125
|
+
const { index } = useDescendant({ title });
|
|
126
|
+
const filteredIndexes = React.useContext(FilteredIndexesContext);
|
|
127
|
+
|
|
128
|
+
// If index is not in filteredIndexes, don't render
|
|
129
|
+
if (!filteredIndexes.includes(index)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return <text>{title}</text>;
|
|
134
|
+
};
|
package/src/dropdown.tsx
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useKeyboard } from "@opentui/react";
|
|
11
|
+
import { TextAttributes } from "@opentui/core";
|
|
12
|
+
import { Theme } from "./theme";
|
|
13
|
+
import { createDescendants } from "./descendants";
|
|
14
|
+
|
|
15
|
+
const logger = console;
|
|
16
|
+
interface CommonProps {
|
|
17
|
+
key?: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// SearchBarInterface provides the common search bar props
|
|
21
|
+
interface SearchBarInterface {
|
|
22
|
+
isLoading?: boolean;
|
|
23
|
+
filtering?: boolean | { keepSectionOrder: boolean };
|
|
24
|
+
onSearchTextChange?: (text: string) => void;
|
|
25
|
+
throttle?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DropdownProps extends SearchBarInterface, CommonProps {
|
|
29
|
+
id?: string;
|
|
30
|
+
tooltip?: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
storeValue?: boolean | undefined;
|
|
33
|
+
selectedValues?: string[];
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
onChange?: (newValue: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DropdownItemProps extends CommonProps {
|
|
39
|
+
title: string;
|
|
40
|
+
value: string;
|
|
41
|
+
icon?: ReactNode;
|
|
42
|
+
|
|
43
|
+
keywords?: string[];
|
|
44
|
+
label?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DropdownSectionProps extends CommonProps {
|
|
48
|
+
title?: string;
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Create descendants for Dropdown items - minimal fields needed
|
|
53
|
+
interface DropdownItemDescendant {
|
|
54
|
+
value: string;
|
|
55
|
+
title: string;
|
|
56
|
+
hidden?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const {
|
|
60
|
+
DescendantsProvider: DropdownDescendantsProvider,
|
|
61
|
+
useDescendants: useDropdownDescendants,
|
|
62
|
+
useDescendant: useDropdownItemDescendant,
|
|
63
|
+
} = createDescendants<DropdownItemDescendant>();
|
|
64
|
+
|
|
65
|
+
// Context for passing data to dropdown items
|
|
66
|
+
interface DropdownContextValue {
|
|
67
|
+
searchText: string;
|
|
68
|
+
filtering?: boolean | { keepSectionOrder: boolean };
|
|
69
|
+
currentSection?: string;
|
|
70
|
+
selectedIndex: number;
|
|
71
|
+
setSelectedIndex?: (index: number) => void;
|
|
72
|
+
selectedValues?: string[];
|
|
73
|
+
onChange?: (value: string) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DropdownContext = createContext<DropdownContextValue>({
|
|
77
|
+
searchText: "",
|
|
78
|
+
filtering: true,
|
|
79
|
+
selectedIndex: 0,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
interface DropdownType {
|
|
83
|
+
(props: DropdownProps): any;
|
|
84
|
+
Item: (props: DropdownItemProps) => any;
|
|
85
|
+
Section: (props: DropdownSectionProps) => any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const Dropdown: DropdownType = (props) => {
|
|
89
|
+
const {
|
|
90
|
+
tooltip,
|
|
91
|
+
onChange,
|
|
92
|
+
selectedValues,
|
|
93
|
+
children,
|
|
94
|
+
placeholder = "Search…",
|
|
95
|
+
storeValue,
|
|
96
|
+
isLoading,
|
|
97
|
+
filtering = true,
|
|
98
|
+
onSearchTextChange,
|
|
99
|
+
throttle,
|
|
100
|
+
} = props;
|
|
101
|
+
|
|
102
|
+
const [selected, setSelected] = useState(0);
|
|
103
|
+
const [searchText, setSearchText] = useState("");
|
|
104
|
+
const inputRef = useRef<any>(null);
|
|
105
|
+
const lastSearchTextRef = useRef("");
|
|
106
|
+
const throttleTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
107
|
+
const descendantsContext = useDropdownDescendants();
|
|
108
|
+
|
|
109
|
+
const inFocus = true;
|
|
110
|
+
|
|
111
|
+
// Create context value for children
|
|
112
|
+
const contextValue = useMemo<DropdownContextValue>(
|
|
113
|
+
() => ({
|
|
114
|
+
searchText,
|
|
115
|
+
filtering,
|
|
116
|
+
currentSection: undefined,
|
|
117
|
+
selectedIndex: selected,
|
|
118
|
+
setSelectedIndex: setSelected,
|
|
119
|
+
selectedValues,
|
|
120
|
+
onChange: (value: string) => selectItem(value),
|
|
121
|
+
}),
|
|
122
|
+
[searchText, filtering, selected, selectedValues],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Reset selected index when search changes
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
setSelected(0);
|
|
128
|
+
}, [searchText]);
|
|
129
|
+
|
|
130
|
+
// Handle search text change with throttling
|
|
131
|
+
const handleSearchTextChange = (text: string) => {
|
|
132
|
+
if (!inFocus) return;
|
|
133
|
+
|
|
134
|
+
setSearchText(text);
|
|
135
|
+
|
|
136
|
+
if (onSearchTextChange) {
|
|
137
|
+
if (throttle) {
|
|
138
|
+
if (throttleTimeoutRef.current) {
|
|
139
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
140
|
+
}
|
|
141
|
+
throttleTimeoutRef.current = setTimeout(() => {
|
|
142
|
+
onSearchTextChange(text);
|
|
143
|
+
}, 300);
|
|
144
|
+
} else {
|
|
145
|
+
onSearchTextChange(text);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const move = (direction: -1 | 1) => {
|
|
151
|
+
const items = Object.values(descendantsContext.map.current)
|
|
152
|
+
.filter((item: any) => item.index !== -1)
|
|
153
|
+
.sort((a: any, b: any) => a.index - b.index);
|
|
154
|
+
|
|
155
|
+
if (items.length === 0) return;
|
|
156
|
+
|
|
157
|
+
let next = selected + direction;
|
|
158
|
+
if (next < 0) next = items.length - 1;
|
|
159
|
+
if (next >= items.length) next = 0;
|
|
160
|
+
setSelected(next);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const selectItem = (itemValue: string) => {
|
|
164
|
+
if (onChange) {
|
|
165
|
+
onChange(itemValue);
|
|
166
|
+
}
|
|
167
|
+
if (storeValue) {
|
|
168
|
+
logger.log("Storing value:", itemValue);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Handle keyboard navigation
|
|
173
|
+
useKeyboard((evt) => {
|
|
174
|
+
if (evt.name === "up") {
|
|
175
|
+
move(-1);
|
|
176
|
+
}
|
|
177
|
+
if (evt.name === "down") {
|
|
178
|
+
move(1);
|
|
179
|
+
}
|
|
180
|
+
if (evt.name === "return") {
|
|
181
|
+
const items = Object.values(descendantsContext.map.current)
|
|
182
|
+
.filter((item: any) => item.index !== -1)
|
|
183
|
+
.sort((a: any, b: any) => a.index - b.index);
|
|
184
|
+
|
|
185
|
+
const currentItem = items[selected];
|
|
186
|
+
if (currentItem?.props) {
|
|
187
|
+
selectItem((currentItem.props as DropdownItemDescendant).value);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<DropdownDescendantsProvider value={descendantsContext}>
|
|
194
|
+
<DropdownContext.Provider value={contextValue}>
|
|
195
|
+
<box>
|
|
196
|
+
<box style={{ paddingLeft: 2, paddingRight: 2 }}>
|
|
197
|
+
<box style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
198
|
+
<box
|
|
199
|
+
style={{
|
|
200
|
+
flexDirection: "row",
|
|
201
|
+
justifyContent: "space-between",
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
<text attributes={TextAttributes.BOLD}>{tooltip}</text>
|
|
205
|
+
<text fg={Theme.textMuted}>esc</text>
|
|
206
|
+
</box>
|
|
207
|
+
<box style={{ paddingTop: 1, paddingBottom: 2 }}>
|
|
208
|
+
<input
|
|
209
|
+
ref={inputRef}
|
|
210
|
+
onInput={(value) => handleSearchTextChange(value)}
|
|
211
|
+
placeholder={placeholder}
|
|
212
|
+
focused={inFocus}
|
|
213
|
+
value={searchText}
|
|
214
|
+
focusedBackgroundColor={Theme.backgroundPanel}
|
|
215
|
+
cursorColor={Theme.primary}
|
|
216
|
+
focusedTextColor={Theme.textMuted}
|
|
217
|
+
/>
|
|
218
|
+
</box>
|
|
219
|
+
</box>
|
|
220
|
+
<box style={{ paddingBottom: 1 }}>
|
|
221
|
+
{/* Render children - they will register as descendants and render themselves */}
|
|
222
|
+
{children}
|
|
223
|
+
</box>
|
|
224
|
+
</box>
|
|
225
|
+
<box
|
|
226
|
+
border={false}
|
|
227
|
+
style={{
|
|
228
|
+
paddingRight: 2,
|
|
229
|
+
paddingLeft: 3,
|
|
230
|
+
paddingBottom: 1,
|
|
231
|
+
paddingTop: 1,
|
|
232
|
+
flexDirection: "row",
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
|
|
236
|
+
↵
|
|
237
|
+
</text>
|
|
238
|
+
<text fg={Theme.textMuted}> select</text>
|
|
239
|
+
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
|
|
240
|
+
{" "}↑↓
|
|
241
|
+
</text>
|
|
242
|
+
<text fg={Theme.textMuted}> navigate</text>
|
|
243
|
+
</box>
|
|
244
|
+
</box>
|
|
245
|
+
</DropdownContext.Provider>
|
|
246
|
+
</DropdownDescendantsProvider>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
function ItemOption(props: {
|
|
251
|
+
title: string;
|
|
252
|
+
icon?: ReactNode;
|
|
253
|
+
active?: boolean;
|
|
254
|
+
current?: boolean;
|
|
255
|
+
label?: string;
|
|
256
|
+
onMouseDown?: () => void;
|
|
257
|
+
onMouseMove?: () => void;
|
|
258
|
+
}) {
|
|
259
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<box
|
|
263
|
+
style={{
|
|
264
|
+
flexDirection: "row",
|
|
265
|
+
backgroundColor: props.active
|
|
266
|
+
? Theme.primary
|
|
267
|
+
: isHovered
|
|
268
|
+
? Theme.backgroundPanel
|
|
269
|
+
: undefined,
|
|
270
|
+
paddingLeft: props.active ? 0 : 1,
|
|
271
|
+
paddingRight: 1,
|
|
272
|
+
justifyContent: "space-between",
|
|
273
|
+
}}
|
|
274
|
+
border={false}
|
|
275
|
+
onMouseMove={() => {
|
|
276
|
+
setIsHovered(true);
|
|
277
|
+
if (props.onMouseMove) props.onMouseMove();
|
|
278
|
+
}}
|
|
279
|
+
onMouseOut={() => setIsHovered(false)}
|
|
280
|
+
onMouseDown={props.onMouseDown}
|
|
281
|
+
>
|
|
282
|
+
<box style={{ flexDirection: "row" }}>
|
|
283
|
+
{props.active && (
|
|
284
|
+
<text fg={Theme.background} selectable={false}>
|
|
285
|
+
›{""}
|
|
286
|
+
</text>
|
|
287
|
+
)}
|
|
288
|
+
{props.icon && (
|
|
289
|
+
<text
|
|
290
|
+
fg={props.active ? Theme.background : Theme.text}
|
|
291
|
+
selectable={false}
|
|
292
|
+
>
|
|
293
|
+
{String(props.icon)}{" "}
|
|
294
|
+
</text>
|
|
295
|
+
)}
|
|
296
|
+
<text
|
|
297
|
+
fg={
|
|
298
|
+
props.active
|
|
299
|
+
? Theme.background
|
|
300
|
+
: props.current
|
|
301
|
+
? Theme.primary
|
|
302
|
+
: Theme.text
|
|
303
|
+
}
|
|
304
|
+
attributes={props.active ? TextAttributes.BOLD : undefined}
|
|
305
|
+
selectable={false}
|
|
306
|
+
>
|
|
307
|
+
{props.title}
|
|
308
|
+
</text>
|
|
309
|
+
</box>
|
|
310
|
+
{props.label && (
|
|
311
|
+
<text
|
|
312
|
+
fg={props.active ? Theme.background : Theme.textMuted}
|
|
313
|
+
attributes={props.active ? TextAttributes.BOLD : undefined}
|
|
314
|
+
selectable={false}
|
|
315
|
+
>
|
|
316
|
+
{props.label}
|
|
317
|
+
</text>
|
|
318
|
+
)}
|
|
319
|
+
</box>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const DropdownItem: (props: DropdownItemProps) => any = (props) => {
|
|
324
|
+
const context = useContext(DropdownContext);
|
|
325
|
+
if (!context) return null;
|
|
326
|
+
|
|
327
|
+
const { searchText, filtering, currentSection, selectedIndex, selectedValues } =
|
|
328
|
+
context;
|
|
329
|
+
|
|
330
|
+
// Apply filtering logic
|
|
331
|
+
const shouldHide = (() => {
|
|
332
|
+
if (!filtering || !searchText.trim()) return false;
|
|
333
|
+
const needle = searchText.toLowerCase().trim();
|
|
334
|
+
const searchableText = [props.title, ...(props.keywords || [])]
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
.join(" ")
|
|
337
|
+
.toLowerCase();
|
|
338
|
+
return !searchableText.includes(needle);
|
|
339
|
+
})();
|
|
340
|
+
|
|
341
|
+
// Register as descendant
|
|
342
|
+
const { index } = useDropdownItemDescendant({
|
|
343
|
+
value: props.value,
|
|
344
|
+
title: props.title,
|
|
345
|
+
hidden: shouldHide,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Don't render if hidden
|
|
349
|
+
if (shouldHide) return null;
|
|
350
|
+
|
|
351
|
+
// Determine if active (index will be -1 if hidden)
|
|
352
|
+
const isActive = index === selectedIndex && index !== -1;
|
|
353
|
+
const isCurrent = selectedValues ? selectedValues.includes(props.value) : false;
|
|
354
|
+
|
|
355
|
+
// Handle mouse events
|
|
356
|
+
const handleMouseMove = () => {
|
|
357
|
+
// Update selected index on hover
|
|
358
|
+
if (
|
|
359
|
+
context.setSelectedIndex &&
|
|
360
|
+
context.selectedIndex !== index &&
|
|
361
|
+
index !== -1
|
|
362
|
+
) {
|
|
363
|
+
context.setSelectedIndex(index);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const handleMouseDown = () => {
|
|
368
|
+
// Trigger selection on click
|
|
369
|
+
if (context.onChange && props.value) {
|
|
370
|
+
context.onChange(props.value);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Render the item directly
|
|
375
|
+
return (
|
|
376
|
+
<ItemOption
|
|
377
|
+
title={props.title}
|
|
378
|
+
icon={props.icon}
|
|
379
|
+
active={isActive}
|
|
380
|
+
current={isCurrent}
|
|
381
|
+
label={props.label}
|
|
382
|
+
onMouseMove={handleMouseMove}
|
|
383
|
+
onMouseDown={handleMouseDown}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const DropdownSection: (props: DropdownSectionProps) => any = (props) => {
|
|
389
|
+
const parentContext = useContext(DropdownContext);
|
|
390
|
+
if (!parentContext) return null;
|
|
391
|
+
|
|
392
|
+
// Create new context with section title
|
|
393
|
+
const sectionContextValue = useMemo(
|
|
394
|
+
() => ({
|
|
395
|
+
...parentContext,
|
|
396
|
+
currentSection: props.title,
|
|
397
|
+
}),
|
|
398
|
+
[parentContext, props.title],
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<>
|
|
403
|
+
{/* Render section title if provided */}
|
|
404
|
+
{props.title && (
|
|
405
|
+
<box style={{ paddingTop: 1, paddingLeft: 1 }}>
|
|
406
|
+
<text fg={Theme.accent} attributes={TextAttributes.BOLD}>
|
|
407
|
+
{props.title}
|
|
408
|
+
</text>
|
|
409
|
+
</box>
|
|
410
|
+
)}
|
|
411
|
+
{/* Render children with section context */}
|
|
412
|
+
<DropdownContext.Provider value={sectionContextValue}>
|
|
413
|
+
{props.children}
|
|
414
|
+
</DropdownContext.Provider>
|
|
415
|
+
</>
|
|
416
|
+
);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
Dropdown.Item = DropdownItem;
|
|
420
|
+
Dropdown.Section = DropdownSection;
|
|
421
|
+
|
|
422
|
+
export default Dropdown;
|
|
423
|
+
export { Dropdown };
|
package/src/theme.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const Theme = {
|
|
2
|
+
// Text colors
|
|
3
|
+
text: "#FFFFFF",
|
|
4
|
+
textMuted: "#999999",
|
|
5
|
+
|
|
6
|
+
// Background colors
|
|
7
|
+
background: "#000000",
|
|
8
|
+
backgroundPanel: "#1E1E1E", // Dark gray panel background
|
|
9
|
+
|
|
10
|
+
// Primary/accent colors
|
|
11
|
+
primary: "#0080FF", // Blue
|
|
12
|
+
accent: "#00FF80", // Light green (was using this for dates)
|
|
13
|
+
|
|
14
|
+
// Accessory colors (from List component)
|
|
15
|
+
info: "#0080FF", // Blue for text accessories
|
|
16
|
+
success: "#00FF80", // Green for date accessories
|
|
17
|
+
warning: "#FF8000", // Orange for tag accessories
|
|
18
|
+
error: "#FF0000", // Red for errors
|
|
19
|
+
|
|
20
|
+
// Additional UI colors
|
|
21
|
+
border: "#333333",
|
|
22
|
+
highlight: "#0080FF",
|
|
23
|
+
selected: "#0080FF",
|
|
24
|
+
yellow: "#FFFF00", // Yellow for icons
|
|
25
|
+
link: "#0080FF", // Blue for links
|
|
26
|
+
|
|
27
|
+
// Transparent
|
|
28
|
+
transparent: undefined, // Use undefined for no background color
|
|
29
|
+
} as const;
|