@abelfubu/dv 0.1.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/dist/ansi-html.d.ts +42 -0
- package/dist/ansi-html.d.ts.map +1 -0
- package/dist/ansi-html.js +327 -0
- package/dist/ansi-output.d.ts +22 -0
- package/dist/ansi-output.d.ts.map +1 -0
- package/dist/ansi-output.js +154 -0
- package/dist/balance-delimiters.d.ts +25 -0
- package/dist/balance-delimiters.d.ts.map +1 -0
- package/dist/balance-delimiters.js +539 -0
- package/dist/balance-delimiters.test.d.ts +2 -0
- package/dist/balance-delimiters.test.d.ts.map +1 -0
- package/dist/balance-delimiters.test.js +1029 -0
- package/dist/cli-copy-notification.test.d.ts +2 -0
- package/dist/cli-copy-notification.test.d.ts.map +1 -0
- package/dist/cli-copy-notification.test.js +80 -0
- package/dist/cli-scroll.test.d.ts +2 -0
- package/dist/cli-scroll.test.d.ts.map +1 -0
- package/dist/cli-scroll.test.js +283 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +976 -0
- package/dist/clipboard.d.ts +16 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +128 -0
- package/dist/components/diff-view.d.ts +32 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.js +123 -0
- package/dist/components/diff-view.test.d.ts +5 -0
- package/dist/components/diff-view.test.d.ts.map +1 -0
- package/dist/components/diff-view.test.js +312 -0
- package/dist/components/directory-tree-view.d.ts +33 -0
- package/dist/components/directory-tree-view.d.ts.map +1 -0
- package/dist/components/directory-tree-view.js +262 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +5 -0
- package/dist/components/toast.d.ts +21 -0
- package/dist/components/toast.d.ts.map +1 -0
- package/dist/components/toast.js +47 -0
- package/dist/diff-cursor-utils.d.ts +20 -0
- package/dist/diff-cursor-utils.d.ts.map +1 -0
- package/dist/diff-cursor-utils.js +105 -0
- package/dist/diff-cursor-utils.test.d.ts +2 -0
- package/dist/diff-cursor-utils.test.d.ts.map +1 -0
- package/dist/diff-cursor-utils.test.js +40 -0
- package/dist/diff-surface-copy.d.ts +23 -0
- package/dist/diff-surface-copy.d.ts.map +1 -0
- package/dist/diff-surface-copy.js +64 -0
- package/dist/diff-surface-copy.test.d.ts +5 -0
- package/dist/diff-surface-copy.test.d.ts.map +1 -0
- package/dist/diff-surface-copy.test.js +142 -0
- package/dist/diff-utils.d.ts +196 -0
- package/dist/diff-utils.d.ts.map +1 -0
- package/dist/diff-utils.js +682 -0
- package/dist/diff-utils.test.d.ts +2 -0
- package/dist/diff-utils.test.d.ts.map +1 -0
- package/dist/diff-utils.test.js +727 -0
- package/dist/directory-tree.d.ts +72 -0
- package/dist/directory-tree.d.ts.map +1 -0
- package/dist/directory-tree.js +161 -0
- package/dist/directory-tree.test.d.ts +2 -0
- package/dist/directory-tree.test.d.ts.map +1 -0
- package/dist/directory-tree.test.js +383 -0
- package/dist/dropdown.d.ts +26 -0
- package/dist/dropdown.d.ts.map +1 -0
- package/dist/dropdown.js +172 -0
- package/dist/dropdown.test.d.ts +2 -0
- package/dist/dropdown.test.d.ts.map +1 -0
- package/dist/dropdown.test.js +106 -0
- package/dist/filter-submodule.e2e.test.d.ts +2 -0
- package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
- package/dist/filter-submodule.e2e.test.js +109 -0
- package/dist/hooks/use-copy-selection.d.ts +29 -0
- package/dist/hooks/use-copy-selection.d.ts.map +1 -0
- package/dist/hooks/use-copy-selection.js +46 -0
- package/dist/kv-codec.d.ts +16 -0
- package/dist/kv-codec.d.ts.map +1 -0
- package/dist/kv-codec.js +36 -0
- package/dist/license.d.ts +14 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +63 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +78 -0
- package/dist/monochrome.d.ts +34 -0
- package/dist/monochrome.d.ts.map +1 -0
- package/dist/monochrome.js +613 -0
- package/dist/monotone.d.ts +22 -0
- package/dist/monotone.d.ts.map +1 -0
- package/dist/monotone.js +185 -0
- package/dist/parsers-config.d.ts +19 -0
- package/dist/parsers-config.d.ts.map +1 -0
- package/dist/parsers-config.js +271 -0
- package/dist/patch-terminal-dimensions.d.ts +2 -0
- package/dist/patch-terminal-dimensions.d.ts.map +1 -0
- package/dist/patch-terminal-dimensions.js +45 -0
- package/dist/stdin-pager.test.d.ts +2 -0
- package/dist/stdin-pager.test.d.ts.map +1 -0
- package/dist/stdin-pager.test.js +497 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +48 -0
- package/dist/themes/github.json +247 -0
- package/dist/themes.d.ts +59 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +248 -0
- package/dist/tree-icons.d.ts +4 -0
- package/dist/tree-icons.d.ts.map +1 -0
- package/dist/tree-icons.js +18 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +13 -0
- package/dist/web-utils.d.ts +56 -0
- package/dist/web-utils.d.ts.map +1 -0
- package/dist/web-utils.js +363 -0
- package/package.json +37 -0
- package/public/jetbrains-mono-nerd.ttf +0 -0
- package/public/jetbrains-mono-nerd.woff2 +0 -0
package/dist/dropdown.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentuah/react/jsx-runtime";
|
|
2
|
+
// Searchable dropdown component for file and theme selection in TUI.
|
|
3
|
+
// Supports keyboard navigation, fuzzy search filtering, and mouse interaction.
|
|
4
|
+
// Used by main diff view for file picker and theme picker overlays.
|
|
5
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { useKeyboard } from "@opentuah/react";
|
|
7
|
+
import { TextAttributes, TextareaRenderable } from "@opentuah/core";
|
|
8
|
+
import { rgbaToHex } from "./themes";
|
|
9
|
+
export function filterDropdownOptions(options, searchText) {
|
|
10
|
+
if (!searchText.trim()) {
|
|
11
|
+
return options;
|
|
12
|
+
}
|
|
13
|
+
const needles = searchText.toLowerCase().trim().split(/\s+/);
|
|
14
|
+
return options.filter((option) => {
|
|
15
|
+
const searchableText = [option.title, ...(option.keywords || [])]
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join(" ")
|
|
18
|
+
.toLowerCase();
|
|
19
|
+
return needles.every((needle) => searchableText.includes(needle));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const Dropdown = (props) => {
|
|
23
|
+
const { tooltip, onChange, onFocus, onEscape, selectedValues = [], options, placeholder = "Search…", itemsPerPage = 10, theme: resolvedTheme, } = props;
|
|
24
|
+
// Convert RGBA theme colors to hex for use in components
|
|
25
|
+
const theme = {
|
|
26
|
+
primary: rgbaToHex(resolvedTheme.primary),
|
|
27
|
+
background: rgbaToHex(resolvedTheme.background),
|
|
28
|
+
backgroundPanel: rgbaToHex(resolvedTheme.backgroundPanel),
|
|
29
|
+
text: rgbaToHex(resolvedTheme.text),
|
|
30
|
+
textMuted: rgbaToHex(resolvedTheme.textMuted),
|
|
31
|
+
};
|
|
32
|
+
const [selected, setSelected] = useState(0);
|
|
33
|
+
const [offset, setOffset] = useState(0);
|
|
34
|
+
const [searchText, setSearchText] = useState("");
|
|
35
|
+
const textareaRef = useRef(null);
|
|
36
|
+
const setTextareaRef = useCallback((node) => {
|
|
37
|
+
textareaRef.current = node;
|
|
38
|
+
}, []);
|
|
39
|
+
const syncSearchFromTextarea = useCallback(() => {
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
const nextText = textareaRef.current?.plainText ?? "";
|
|
42
|
+
setSearchText(nextText);
|
|
43
|
+
});
|
|
44
|
+
}, []);
|
|
45
|
+
const inFocus = true;
|
|
46
|
+
// Filter options based on search
|
|
47
|
+
const filteredOptions = filterDropdownOptions(options, searchText);
|
|
48
|
+
// Get visible options for current page
|
|
49
|
+
const visibleOptions = filteredOptions.slice(offset, offset + itemsPerPage);
|
|
50
|
+
// Reset selected index and offset when search changes
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setSelected(0);
|
|
53
|
+
setOffset(0);
|
|
54
|
+
// Call onFocus for the first filtered item
|
|
55
|
+
const firstOption = filteredOptions[0];
|
|
56
|
+
if (firstOption && onFocus) {
|
|
57
|
+
onFocus(firstOption.value);
|
|
58
|
+
}
|
|
59
|
+
}, [searchText, filteredOptions.length]);
|
|
60
|
+
const move = (direction) => {
|
|
61
|
+
const itemCount = filteredOptions.length;
|
|
62
|
+
if (itemCount === 0)
|
|
63
|
+
return;
|
|
64
|
+
if (direction === 1) {
|
|
65
|
+
setSelected((prev) => {
|
|
66
|
+
const nextIndex = (prev + 1) % itemCount;
|
|
67
|
+
const visibleEnd = offset + itemsPerPage - 1;
|
|
68
|
+
if (prev === visibleEnd && nextIndex < itemCount && nextIndex > prev) {
|
|
69
|
+
setOffset(offset + 1);
|
|
70
|
+
}
|
|
71
|
+
else if (nextIndex < prev) {
|
|
72
|
+
setOffset(0);
|
|
73
|
+
}
|
|
74
|
+
// Call onFocus with the newly focused item
|
|
75
|
+
const focusedOption = filteredOptions[nextIndex];
|
|
76
|
+
if (focusedOption && onFocus) {
|
|
77
|
+
onFocus(focusedOption.value);
|
|
78
|
+
}
|
|
79
|
+
return nextIndex;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
setSelected((prev) => {
|
|
84
|
+
const nextIndex = (prev - 1 + itemCount) % itemCount;
|
|
85
|
+
if (nextIndex < offset) {
|
|
86
|
+
setOffset(Math.max(0, nextIndex));
|
|
87
|
+
}
|
|
88
|
+
else if (nextIndex >= offset + itemsPerPage) {
|
|
89
|
+
setOffset(Math.max(0, itemCount - itemsPerPage));
|
|
90
|
+
}
|
|
91
|
+
// Call onFocus with the newly focused item
|
|
92
|
+
const focusedOption = filteredOptions[nextIndex];
|
|
93
|
+
if (focusedOption && onFocus) {
|
|
94
|
+
onFocus(focusedOption.value);
|
|
95
|
+
}
|
|
96
|
+
return nextIndex;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const selectItem = (itemValue) => {
|
|
101
|
+
if (onChange) {
|
|
102
|
+
onChange(itemValue);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
// Handle keyboard navigation
|
|
106
|
+
useKeyboard((evt) => {
|
|
107
|
+
if (evt.name === "escape") {
|
|
108
|
+
evt.stopPropagation();
|
|
109
|
+
if (onEscape) {
|
|
110
|
+
onEscape();
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (evt.name === "up") {
|
|
115
|
+
move(-1);
|
|
116
|
+
}
|
|
117
|
+
if (evt.name === "down") {
|
|
118
|
+
move(1);
|
|
119
|
+
}
|
|
120
|
+
if (evt.name === "return") {
|
|
121
|
+
const currentOption = filteredOptions[selected];
|
|
122
|
+
if (currentOption) {
|
|
123
|
+
selectItem(currentOption.value);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return (_jsxs("box", { children: [_jsxs("box", { style: { paddingLeft: 2, paddingRight: 2 }, children: [_jsxs("box", { style: { paddingLeft: 1, paddingRight: 1 }, children: [_jsxs("box", { style: {
|
|
129
|
+
flexDirection: "row",
|
|
130
|
+
justifyContent: "space-between",
|
|
131
|
+
}, children: [_jsx("text", { attributes: TextAttributes.BOLD, children: tooltip }), _jsx("text", { fg: theme.textMuted, children: "esc" })] }), _jsxs("box", { style: { paddingTop: 1, paddingBottom: 1, flexDirection: "row" }, children: [_jsx("text", { flexShrink: 0, fg: theme.primary, children: "> " }), _jsx("textarea", { ref: setTextareaRef, height: 1, flexGrow: 1, wrapMode: "none", onContentChange: syncSearchFromTextarea, placeholder: placeholder, focused: inFocus, focusedBackgroundColor: theme.backgroundPanel, cursorColor: theme.primary, focusedTextColor: theme.textMuted })] })] }), _jsx("box", { style: { paddingBottom: 1 }, children: visibleOptions.map((option, idx) => {
|
|
132
|
+
const globalIndex = offset + idx;
|
|
133
|
+
const isActive = globalIndex === selected;
|
|
134
|
+
const isCurrent = selectedValues.includes(option.value);
|
|
135
|
+
return (_jsx("box", { children: _jsx(ItemOption, { title: option.title, icon: option.icon, active: isActive, current: isCurrent, label: option.label, theme: theme, onMouseMove: () => {
|
|
136
|
+
setSelected(globalIndex);
|
|
137
|
+
if (onFocus)
|
|
138
|
+
onFocus(option.value);
|
|
139
|
+
}, onMouseDown: () => selectItem(option.value) }) }, option.value));
|
|
140
|
+
}) })] }), _jsxs("box", { border: false, style: {
|
|
141
|
+
paddingRight: 2,
|
|
142
|
+
paddingLeft: 3,
|
|
143
|
+
paddingBottom: 1,
|
|
144
|
+
paddingTop: 1,
|
|
145
|
+
flexDirection: "row",
|
|
146
|
+
}, children: [_jsx("text", { fg: theme.text, attributes: TextAttributes.BOLD, children: "\u21B5" }), _jsx("text", { fg: theme.textMuted, children: " select" }), _jsxs("text", { fg: theme.text, attributes: TextAttributes.BOLD, children: [" ", "\u2191\u2193"] }), _jsx("text", { fg: theme.textMuted, children: " navigate" })] })] }));
|
|
147
|
+
};
|
|
148
|
+
function ItemOption(props) {
|
|
149
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
150
|
+
const { theme } = props;
|
|
151
|
+
return (_jsxs("box", { style: {
|
|
152
|
+
flexDirection: "row",
|
|
153
|
+
backgroundColor: props.active
|
|
154
|
+
? theme.primary
|
|
155
|
+
: isHovered
|
|
156
|
+
? theme.backgroundPanel
|
|
157
|
+
: undefined,
|
|
158
|
+
paddingLeft: props.active ? 0 : 1,
|
|
159
|
+
paddingRight: 1,
|
|
160
|
+
justifyContent: "space-between",
|
|
161
|
+
}, border: false, onMouseMove: () => {
|
|
162
|
+
setIsHovered(true);
|
|
163
|
+
if (props.onMouseMove)
|
|
164
|
+
props.onMouseMove();
|
|
165
|
+
}, onMouseOut: () => setIsHovered(false), onMouseDown: props.onMouseDown, children: [_jsxs("box", { style: { flexDirection: "row" }, children: [props.active && (_jsxs("text", { fg: theme.background, selectable: false, children: ["\u203A", ""] })), props.icon && (_jsxs("text", { fg: props.active ? theme.background : theme.text, selectable: false, children: [String(props.icon), " "] })), _jsx("text", { fg: props.active
|
|
166
|
+
? theme.background
|
|
167
|
+
: props.current
|
|
168
|
+
? theme.primary
|
|
169
|
+
: theme.text, attributes: props.active ? TextAttributes.BOLD : undefined, selectable: false, children: props.title })] }), props.label && (_jsx("text", { fg: props.active ? theme.background : theme.textMuted, attributes: props.active ? TextAttributes.BOLD : undefined, selectable: false, children: props.label }))] }));
|
|
170
|
+
}
|
|
171
|
+
export default Dropdown;
|
|
172
|
+
export { Dropdown };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dropdown.test.d.ts","sourceRoot":"","sources":["../src/dropdown.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@opentuah/react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { testRender } from "@opentuah/react/test-utils";
|
|
6
|
+
import Dropdown, { filterDropdownOptions } from "./dropdown.js";
|
|
7
|
+
import { getResolvedTheme } from "./themes.js";
|
|
8
|
+
const themeOptions = [
|
|
9
|
+
{ title: "GitHub", value: "github" },
|
|
10
|
+
{ title: "Tokyo Night", value: "tokyonight" },
|
|
11
|
+
];
|
|
12
|
+
function DropdownHarness() {
|
|
13
|
+
const [open, setOpen] = React.useState(true);
|
|
14
|
+
const theme = getResolvedTheme("github");
|
|
15
|
+
if (!open) {
|
|
16
|
+
return _jsx("text", { children: "closed" });
|
|
17
|
+
}
|
|
18
|
+
return (_jsx(Dropdown, { tooltip: "Select theme", options: themeOptions, selectedValues: [], onEscape: () => setOpen(false), theme: theme }));
|
|
19
|
+
}
|
|
20
|
+
// Suppress React act() warnings for opentui input tests.
|
|
21
|
+
// opentui's mockInput triggers state updates asynchronously via stdin parsing,
|
|
22
|
+
// which inherently happens outside act(). This is expected behavior for TUI
|
|
23
|
+
// component testing — not a real problem.
|
|
24
|
+
// testRender sets IS_REACT_ACT_ENVIRONMENT=true, so we must disable it after.
|
|
25
|
+
async function setupTest(jsx, opts) {
|
|
26
|
+
const setup = await testRender(jsx, opts);
|
|
27
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
|
28
|
+
return setup;
|
|
29
|
+
}
|
|
30
|
+
describe("Dropdown", () => {
|
|
31
|
+
let testSetup;
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (testSetup) {
|
|
34
|
+
testSetup.renderer.destroy();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
it("closes on escape", async () => {
|
|
38
|
+
testSetup = await setupTest(_jsx(DropdownHarness, {}), {
|
|
39
|
+
width: 50,
|
|
40
|
+
height: 12,
|
|
41
|
+
});
|
|
42
|
+
await testSetup.renderOnce();
|
|
43
|
+
let frame = testSetup.captureCharFrame();
|
|
44
|
+
expect(frame).toContain("Select theme");
|
|
45
|
+
// Press escape - emits to stdin which triggers useKeyboard handler
|
|
46
|
+
testSetup.mockInput.pressEscape();
|
|
47
|
+
// Allow stdin event to be parsed and trigger React state update
|
|
48
|
+
await new Promise(r => setTimeout(r, 10));
|
|
49
|
+
// Render the updated state
|
|
50
|
+
await testSetup.renderOnce();
|
|
51
|
+
frame = testSetup.captureCharFrame();
|
|
52
|
+
expect(frame).toContain("closed");
|
|
53
|
+
expect(frame).not.toContain("Select theme");
|
|
54
|
+
});
|
|
55
|
+
it("filters visible options when typing in textarea", async () => {
|
|
56
|
+
testSetup = await setupTest(_jsx(DropdownHarness, {}), {
|
|
57
|
+
width: 50,
|
|
58
|
+
height: 12,
|
|
59
|
+
});
|
|
60
|
+
await testSetup.renderOnce();
|
|
61
|
+
let frame = testSetup.captureCharFrame();
|
|
62
|
+
// Both options visible initially
|
|
63
|
+
expect(frame).toContain("GitHub");
|
|
64
|
+
expect(frame).toContain("Tokyo Night");
|
|
65
|
+
// Type "tokyo" to filter — should hide GitHub and show only Tokyo Night
|
|
66
|
+
await testSetup.mockInput.typeText("tokyo");
|
|
67
|
+
await new Promise(r => setTimeout(r, 50));
|
|
68
|
+
await testSetup.renderOnce();
|
|
69
|
+
frame = testSetup.captureCharFrame();
|
|
70
|
+
expect(frame).toContain("Tokyo Night");
|
|
71
|
+
expect(frame).not.toContain("GitHub");
|
|
72
|
+
});
|
|
73
|
+
it("filters options by title and keyword intersections", () => {
|
|
74
|
+
const options = [
|
|
75
|
+
{ title: "GitHub", value: "github", keywords: ["git", "hub"] },
|
|
76
|
+
{ title: "Tokyo Night", value: "tokyonight", keywords: ["tokyo", "night"] },
|
|
77
|
+
{ title: "Ayu", value: "ayu", keywords: ["light"] },
|
|
78
|
+
];
|
|
79
|
+
const tokyoOnly = filterDropdownOptions(options, "tokyo");
|
|
80
|
+
expect(tokyoOnly).toMatchInlineSnapshot(`
|
|
81
|
+
[
|
|
82
|
+
{
|
|
83
|
+
"keywords": [
|
|
84
|
+
"tokyo",
|
|
85
|
+
"night",
|
|
86
|
+
],
|
|
87
|
+
"title": "Tokyo Night",
|
|
88
|
+
"value": "tokyonight",
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
`);
|
|
92
|
+
const intersection = filterDropdownOptions(options, "git hub");
|
|
93
|
+
expect(intersection).toMatchInlineSnapshot(`
|
|
94
|
+
[
|
|
95
|
+
{
|
|
96
|
+
"keywords": [
|
|
97
|
+
"git",
|
|
98
|
+
"hub",
|
|
99
|
+
],
|
|
100
|
+
"title": "GitHub",
|
|
101
|
+
"value": "github",
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
`);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filter-submodule.e2e.test.d.ts","sourceRoot":"","sources":["../src/filter-submodule.e2e.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// End-to-end tests for --filter behavior when dirty submodule diffs are merged.
|
|
2
|
+
// Uses real git repositories and real CLI invocations to mirror user workflows.
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
4
|
+
import child_process from "child_process";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import stripAnsi from "strip-ansi";
|
|
8
|
+
const TEMP_ROOT = path.join(import.meta.dir, ".test-filter-submodule-e2e-tmp");
|
|
9
|
+
const CLI_PATH = path.join(import.meta.dir, "cli.tsx");
|
|
10
|
+
function runCommand(command, args, cwd) {
|
|
11
|
+
return child_process.execFileSync(command, args, {
|
|
12
|
+
cwd,
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
GIT_ALLOW_PROTOCOL: "file",
|
|
17
|
+
},
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function runGit(cwd, args) {
|
|
22
|
+
return runCommand("git", args, cwd);
|
|
23
|
+
}
|
|
24
|
+
function configureGitIdentity(repoPath) {
|
|
25
|
+
runGit(repoPath, ["config", "user.name", "Critique Tests"]);
|
|
26
|
+
runGit(repoPath, ["config", "user.email", "tests@critique.local"]);
|
|
27
|
+
}
|
|
28
|
+
function writeFile(filePath, content) {
|
|
29
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
30
|
+
fs.writeFileSync(filePath, content);
|
|
31
|
+
}
|
|
32
|
+
function createFixtureRepo(testName) {
|
|
33
|
+
const slug = testName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
34
|
+
const fixtureRoot = fs.mkdtempSync(path.join(TEMP_ROOT, `${slug}-`));
|
|
35
|
+
const submoduleSource = path.join(fixtureRoot, "submodule-source");
|
|
36
|
+
const parentRepo = path.join(fixtureRoot, "parent-repo");
|
|
37
|
+
fs.mkdirSync(submoduleSource, { recursive: true });
|
|
38
|
+
runGit(submoduleSource, ["init"]);
|
|
39
|
+
configureGitIdentity(submoduleSource);
|
|
40
|
+
writeFile(path.join(submoduleSource, "packages/react/src/widget.ts"), [
|
|
41
|
+
"export const widget = 'submodule'",
|
|
42
|
+
"export const widgetVersion = 1",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n"));
|
|
45
|
+
runGit(submoduleSource, ["add", "."]);
|
|
46
|
+
runGit(submoduleSource, ["commit", "-m", "initial submodule commit"]);
|
|
47
|
+
fs.mkdirSync(parentRepo, { recursive: true });
|
|
48
|
+
runGit(parentRepo, ["init"]);
|
|
49
|
+
configureGitIdentity(parentRepo);
|
|
50
|
+
writeFile(path.join(parentRepo, "src/root.ts"), [
|
|
51
|
+
"export const rootFile = true",
|
|
52
|
+
"export const rootVersion = 1",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n"));
|
|
55
|
+
runGit(parentRepo, ["add", "."]);
|
|
56
|
+
runGit(parentRepo, ["commit", "-m", "initial parent commit"]);
|
|
57
|
+
runGit(parentRepo, [
|
|
58
|
+
"-c",
|
|
59
|
+
"protocol.file.allow=always",
|
|
60
|
+
"submodule",
|
|
61
|
+
"add",
|
|
62
|
+
submoduleSource,
|
|
63
|
+
"vendor/opentui",
|
|
64
|
+
]);
|
|
65
|
+
runGit(parentRepo, ["add", "."]);
|
|
66
|
+
runGit(parentRepo, ["commit", "-m", "add submodule"]);
|
|
67
|
+
fs.appendFileSync(path.join(parentRepo, "src/root.ts"), "export const rootChanged = true\n");
|
|
68
|
+
fs.appendFileSync(path.join(parentRepo, "vendor/opentui/packages/react/src/widget.ts"), "export const submoduleChanged = true\n");
|
|
69
|
+
return parentRepo;
|
|
70
|
+
}
|
|
71
|
+
function runCritiqueWithFilters(repoPath, filters) {
|
|
72
|
+
const args = [CLI_PATH, "--scrollback", "--no-stdin"];
|
|
73
|
+
for (const filter of filters) {
|
|
74
|
+
args.push("--filter", filter);
|
|
75
|
+
}
|
|
76
|
+
const output = runCommand("bun", args, repoPath);
|
|
77
|
+
return stripAnsi(output).replace(/\r/g, "").trim();
|
|
78
|
+
}
|
|
79
|
+
describe("e2e: filter with dirty submodules", () => {
|
|
80
|
+
beforeAll(() => {
|
|
81
|
+
fs.mkdirSync(TEMP_ROOT, { recursive: true });
|
|
82
|
+
});
|
|
83
|
+
afterAll(() => {
|
|
84
|
+
fs.rmSync(TEMP_ROOT, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
test("user example: critique --scrollback --filter 'vendor/opentui/**'", () => {
|
|
87
|
+
const repoPath = createFixtureRepo("submodule-glob-filter");
|
|
88
|
+
const output = runCritiqueWithFilters(repoPath, ["vendor/opentui/**"]);
|
|
89
|
+
expect(output).toContain("vendor/opentui/packages/react/src/widget.ts");
|
|
90
|
+
expect(output).toContain("submoduleChanged");
|
|
91
|
+
expect(output).not.toContain("src/root.ts");
|
|
92
|
+
expect(output).not.toContain("unknown +0-0");
|
|
93
|
+
}, 120000);
|
|
94
|
+
test("user example: critique --scrollback --filter 'src'", () => {
|
|
95
|
+
const repoPath = createFixtureRepo("plain-path-filter");
|
|
96
|
+
const output = runCritiqueWithFilters(repoPath, ["src"]);
|
|
97
|
+
expect(output).toContain("src/root.ts");
|
|
98
|
+
expect(output).toContain("rootChanged");
|
|
99
|
+
expect(output).not.toContain("vendor/opentui/packages/react/src/widget.ts");
|
|
100
|
+
expect(output).not.toContain("unknown +0-0");
|
|
101
|
+
}, 120000);
|
|
102
|
+
test("user example: critique --scrollback --filter '.'", () => {
|
|
103
|
+
const repoPath = createFixtureRepo("dot-root-filter");
|
|
104
|
+
const output = runCritiqueWithFilters(repoPath, ["."]);
|
|
105
|
+
expect(output).toContain("src/root.ts");
|
|
106
|
+
expect(output).toContain("vendor/opentui/packages/react/src/widget.ts");
|
|
107
|
+
expect(output).not.toContain("unknown +0-0");
|
|
108
|
+
}, 120000);
|
|
109
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props for the mouseup handler returned by useCopySelection
|
|
3
|
+
*/
|
|
4
|
+
export interface CopySelectionHandlers {
|
|
5
|
+
/** Attach this to the root box's onMouseUp prop */
|
|
6
|
+
onMouseUp: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Hook that provides a mouseup handler for copy-on-selection behavior.
|
|
10
|
+
* When the user releases the mouse button after selecting text,
|
|
11
|
+
* the selected text is automatically copied to the clipboard.
|
|
12
|
+
*
|
|
13
|
+
* @returns Object with onMouseUp handler to attach to root component
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* function App() {
|
|
18
|
+
* const { onMouseUp } = useCopySelection()
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <box onMouseUp={onMouseUp}>
|
|
22
|
+
* <text>Select this text and release to copy</text>
|
|
23
|
+
* </box>
|
|
24
|
+
* )
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function useCopySelection(): CopySelectionHandlers;
|
|
29
|
+
//# sourceMappingURL=use-copy-selection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-copy-selection.d.ts","sourceRoot":"","sources":["../../src/hooks/use-copy-selection.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,mDAAmD;IACnD,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC/B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,IAAI,qBAAqB,CAqBxD"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Hook for copy-to-clipboard on mouse selection release.
|
|
2
|
+
// Automatically copies selected text to clipboard when user releases mouse button.
|
|
3
|
+
// Uses native clipboard commands (pbcopy, xclip, etc.) with OSC52 fallback.
|
|
4
|
+
import { useRenderer } from "@opentuah/react";
|
|
5
|
+
import { copyToClipboard } from "../clipboard.js";
|
|
6
|
+
/**
|
|
7
|
+
* Hook that provides a mouseup handler for copy-on-selection behavior.
|
|
8
|
+
* When the user releases the mouse button after selecting text,
|
|
9
|
+
* the selected text is automatically copied to the clipboard.
|
|
10
|
+
*
|
|
11
|
+
* @returns Object with onMouseUp handler to attach to root component
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* function App() {
|
|
16
|
+
* const { onMouseUp } = useCopySelection()
|
|
17
|
+
*
|
|
18
|
+
* return (
|
|
19
|
+
* <box onMouseUp={onMouseUp}>
|
|
20
|
+
* <text>Select this text and release to copy</text>
|
|
21
|
+
* </box>
|
|
22
|
+
* )
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function useCopySelection() {
|
|
27
|
+
const renderer = useRenderer();
|
|
28
|
+
const onMouseUp = async () => {
|
|
29
|
+
const selection = renderer.getSelection();
|
|
30
|
+
if (!selection)
|
|
31
|
+
return;
|
|
32
|
+
if (selection.isDragging)
|
|
33
|
+
return;
|
|
34
|
+
const text = selection.getSelectedText();
|
|
35
|
+
if (!text || text.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
await copyToClipboard(text, (value) => renderer.copyToClipboardOSC52(value));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Silent fail - user can manually copy if needed
|
|
42
|
+
}
|
|
43
|
+
renderer.clearSelection();
|
|
44
|
+
};
|
|
45
|
+
return { onMouseUp };
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type KvValueMetadata = {
|
|
2
|
+
contentType?: string;
|
|
3
|
+
contentEncoding?: "gzip";
|
|
4
|
+
schemaVersion?: 1;
|
|
5
|
+
};
|
|
6
|
+
export declare const KV_SCHEMA_VERSION: 1;
|
|
7
|
+
export declare function buildKvPutOptions(ttlSeconds?: number, metadata?: KvValueMetadata): {
|
|
8
|
+
expirationTtl?: number;
|
|
9
|
+
metadata?: KvValueMetadata;
|
|
10
|
+
} | undefined;
|
|
11
|
+
export declare function gzipArrayBuffer(buffer: ArrayBuffer): Promise<ArrayBuffer>;
|
|
12
|
+
export declare function gunzipArrayBuffer(buffer: ArrayBuffer): Promise<ArrayBuffer>;
|
|
13
|
+
export declare function gzipText(value: string): Promise<ArrayBuffer>;
|
|
14
|
+
export declare function decodeTextFromKv(value: ArrayBuffer, metadata: KvValueMetadata | null): Promise<string>;
|
|
15
|
+
export declare function decodeBinaryFromKv(value: ArrayBuffer, metadata: KvValueMetadata | null): Promise<ArrayBuffer>;
|
|
16
|
+
//# sourceMappingURL=kv-codec.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kv-codec.d.ts","sourceRoot":"","sources":["../src/kv-codec.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,eAAe,GAAG;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,CAAC,CAAA;CAClB,CAAA;AAED,eAAO,MAAM,iBAAiB,EAAG,CAAU,CAAA;AAE3C,wBAAgB,iBAAiB,CAC/B,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,eAAe,GACzB;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,eAAe,CAAA;CAAE,GAAG,SAAS,CASpE;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAG/E;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAGjF;AAED,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAGlE;AAED,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,eAAe,GAAG,IAAI,GAC/B,OAAO,CAAC,MAAM,CAAC,CAKjB;AAED,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,eAAe,GAAG,IAAI,GAC/B,OAAO,CAAC,WAAW,CAAC,CAKtB"}
|
package/dist/kv-codec.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// KV value compression/decompression helpers for Cloudflare Worker storage.
|
|
2
|
+
// Provides metadata-aware gzip encoding and decoding for text and binary payloads.
|
|
3
|
+
export const KV_SCHEMA_VERSION = 1;
|
|
4
|
+
export function buildKvPutOptions(ttlSeconds, metadata) {
|
|
5
|
+
if (ttlSeconds === undefined && metadata === undefined) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
...(ttlSeconds !== undefined ? { expirationTtl: ttlSeconds } : {}),
|
|
10
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export async function gzipArrayBuffer(buffer) {
|
|
14
|
+
const stream = new Blob([buffer]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
15
|
+
return await new Response(stream).arrayBuffer();
|
|
16
|
+
}
|
|
17
|
+
export async function gunzipArrayBuffer(buffer) {
|
|
18
|
+
const stream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
19
|
+
return await new Response(stream).arrayBuffer();
|
|
20
|
+
}
|
|
21
|
+
export async function gzipText(value) {
|
|
22
|
+
const bytes = new TextEncoder().encode(value);
|
|
23
|
+
return gzipArrayBuffer(bytes.buffer);
|
|
24
|
+
}
|
|
25
|
+
export async function decodeTextFromKv(value, metadata) {
|
|
26
|
+
const buffer = metadata?.contentEncoding === "gzip"
|
|
27
|
+
? await gunzipArrayBuffer(value)
|
|
28
|
+
: value;
|
|
29
|
+
return new TextDecoder().decode(buffer);
|
|
30
|
+
}
|
|
31
|
+
export async function decodeBinaryFromKv(value, metadata) {
|
|
32
|
+
if (metadata?.contentEncoding === "gzip") {
|
|
33
|
+
return await gunzipArrayBuffer(value);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface StoredLicense {
|
|
2
|
+
key?: string;
|
|
3
|
+
ownerSecret?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function loadStoredLicense(): StoredLicense | null;
|
|
6
|
+
export declare function saveStoredLicense(license: StoredLicense): void;
|
|
7
|
+
export declare function loadStoredLicenseKey(): string | null;
|
|
8
|
+
export declare function saveStoredLicenseKey(key: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Load existing owner secret or create one if it doesn't exist.
|
|
11
|
+
* The owner secret is used to authenticate diff deletion requests.
|
|
12
|
+
*/
|
|
13
|
+
export declare function loadOrCreateOwnerSecret(): string;
|
|
14
|
+
//# sourceMappingURL=license.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"license.d.ts","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,wBAAgB,iBAAiB,IAAI,aAAa,GAAG,IAAI,CAOxD;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAS9D;AAED,wBAAgB,oBAAoB,IAAI,MAAM,GAAG,IAAI,CAIpD;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAUtD;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CAiBhD"}
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// License key storage in ~/.critique/license.json.
|
|
2
|
+
// Also stores owner secret for authenticating diff deletion.
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
const LICENSE_DIR = join(homedir(), ".critique");
|
|
8
|
+
const LICENSE_FILE = join(LICENSE_DIR, "license.json");
|
|
9
|
+
export function loadStoredLicense() {
|
|
10
|
+
try {
|
|
11
|
+
const data = fs.readFileSync(LICENSE_FILE, "utf-8");
|
|
12
|
+
return JSON.parse(data);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function saveStoredLicense(license) {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(LICENSE_DIR)) {
|
|
21
|
+
fs.mkdirSync(LICENSE_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
fs.writeFileSync(LICENSE_FILE, JSON.stringify(license, null, 2));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Ignore write errors
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function loadStoredLicenseKey() {
|
|
30
|
+
const license = loadStoredLicense();
|
|
31
|
+
if (!license?.key)
|
|
32
|
+
return null;
|
|
33
|
+
return license.key.trim() || null;
|
|
34
|
+
}
|
|
35
|
+
export function saveStoredLicenseKey(key) {
|
|
36
|
+
const trimmed = key.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return;
|
|
39
|
+
// Preserve existing owner secret when saving license key
|
|
40
|
+
const existing = loadStoredLicense();
|
|
41
|
+
saveStoredLicense({
|
|
42
|
+
...existing,
|
|
43
|
+
key: trimmed,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Load existing owner secret or create one if it doesn't exist.
|
|
48
|
+
* The owner secret is used to authenticate diff deletion requests.
|
|
49
|
+
*/
|
|
50
|
+
export function loadOrCreateOwnerSecret() {
|
|
51
|
+
const existing = loadStoredLicense();
|
|
52
|
+
if (existing?.ownerSecret) {
|
|
53
|
+
return existing.ownerSecret;
|
|
54
|
+
}
|
|
55
|
+
// Generate new owner secret
|
|
56
|
+
const ownerSecret = randomUUID();
|
|
57
|
+
// Save while preserving existing license key
|
|
58
|
+
saveStoredLicense({
|
|
59
|
+
...existing,
|
|
60
|
+
ownerSecret,
|
|
61
|
+
});
|
|
62
|
+
return ownerSecret;
|
|
63
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const logger: {
|
|
2
|
+
log(...args: unknown[]): void;
|
|
3
|
+
info(...args: unknown[]): void;
|
|
4
|
+
warn(...args: unknown[]): void;
|
|
5
|
+
error(...args: unknown[]): void;
|
|
6
|
+
debug(...args: unknown[]): void;
|
|
7
|
+
};
|
|
8
|
+
export default logger;
|
|
9
|
+
//# sourceMappingURL=logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AA8CA,eAAO,MAAM,MAAM;iBACJ,OAAO,EAAE;kBAOR,OAAO,EAAE;kBAOT,OAAO,EAAE;mBAOR,OAAO,EAAE;mBAQT,OAAO,EAAE;CAMzB,CAAA;AAED,eAAe,MAAM,CAAA"}
|