@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.
Files changed (118) hide show
  1. package/dist/ansi-html.d.ts +42 -0
  2. package/dist/ansi-html.d.ts.map +1 -0
  3. package/dist/ansi-html.js +327 -0
  4. package/dist/ansi-output.d.ts +22 -0
  5. package/dist/ansi-output.d.ts.map +1 -0
  6. package/dist/ansi-output.js +154 -0
  7. package/dist/balance-delimiters.d.ts +25 -0
  8. package/dist/balance-delimiters.d.ts.map +1 -0
  9. package/dist/balance-delimiters.js +539 -0
  10. package/dist/balance-delimiters.test.d.ts +2 -0
  11. package/dist/balance-delimiters.test.d.ts.map +1 -0
  12. package/dist/balance-delimiters.test.js +1029 -0
  13. package/dist/cli-copy-notification.test.d.ts +2 -0
  14. package/dist/cli-copy-notification.test.d.ts.map +1 -0
  15. package/dist/cli-copy-notification.test.js +80 -0
  16. package/dist/cli-scroll.test.d.ts +2 -0
  17. package/dist/cli-scroll.test.d.ts.map +1 -0
  18. package/dist/cli-scroll.test.js +283 -0
  19. package/dist/cli.d.ts +9 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +976 -0
  22. package/dist/clipboard.d.ts +16 -0
  23. package/dist/clipboard.d.ts.map +1 -0
  24. package/dist/clipboard.js +128 -0
  25. package/dist/components/diff-view.d.ts +32 -0
  26. package/dist/components/diff-view.d.ts.map +1 -0
  27. package/dist/components/diff-view.js +123 -0
  28. package/dist/components/diff-view.test.d.ts +5 -0
  29. package/dist/components/diff-view.test.d.ts.map +1 -0
  30. package/dist/components/diff-view.test.js +312 -0
  31. package/dist/components/directory-tree-view.d.ts +33 -0
  32. package/dist/components/directory-tree-view.d.ts.map +1 -0
  33. package/dist/components/directory-tree-view.js +262 -0
  34. package/dist/components/index.d.ts +4 -0
  35. package/dist/components/index.d.ts.map +1 -0
  36. package/dist/components/index.js +5 -0
  37. package/dist/components/toast.d.ts +21 -0
  38. package/dist/components/toast.d.ts.map +1 -0
  39. package/dist/components/toast.js +47 -0
  40. package/dist/diff-cursor-utils.d.ts +20 -0
  41. package/dist/diff-cursor-utils.d.ts.map +1 -0
  42. package/dist/diff-cursor-utils.js +105 -0
  43. package/dist/diff-cursor-utils.test.d.ts +2 -0
  44. package/dist/diff-cursor-utils.test.d.ts.map +1 -0
  45. package/dist/diff-cursor-utils.test.js +40 -0
  46. package/dist/diff-surface-copy.d.ts +23 -0
  47. package/dist/diff-surface-copy.d.ts.map +1 -0
  48. package/dist/diff-surface-copy.js +64 -0
  49. package/dist/diff-surface-copy.test.d.ts +5 -0
  50. package/dist/diff-surface-copy.test.d.ts.map +1 -0
  51. package/dist/diff-surface-copy.test.js +142 -0
  52. package/dist/diff-utils.d.ts +196 -0
  53. package/dist/diff-utils.d.ts.map +1 -0
  54. package/dist/diff-utils.js +682 -0
  55. package/dist/diff-utils.test.d.ts +2 -0
  56. package/dist/diff-utils.test.d.ts.map +1 -0
  57. package/dist/diff-utils.test.js +727 -0
  58. package/dist/directory-tree.d.ts +72 -0
  59. package/dist/directory-tree.d.ts.map +1 -0
  60. package/dist/directory-tree.js +161 -0
  61. package/dist/directory-tree.test.d.ts +2 -0
  62. package/dist/directory-tree.test.d.ts.map +1 -0
  63. package/dist/directory-tree.test.js +383 -0
  64. package/dist/dropdown.d.ts +26 -0
  65. package/dist/dropdown.d.ts.map +1 -0
  66. package/dist/dropdown.js +172 -0
  67. package/dist/dropdown.test.d.ts +2 -0
  68. package/dist/dropdown.test.d.ts.map +1 -0
  69. package/dist/dropdown.test.js +106 -0
  70. package/dist/filter-submodule.e2e.test.d.ts +2 -0
  71. package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
  72. package/dist/filter-submodule.e2e.test.js +109 -0
  73. package/dist/hooks/use-copy-selection.d.ts +29 -0
  74. package/dist/hooks/use-copy-selection.d.ts.map +1 -0
  75. package/dist/hooks/use-copy-selection.js +46 -0
  76. package/dist/kv-codec.d.ts +16 -0
  77. package/dist/kv-codec.d.ts.map +1 -0
  78. package/dist/kv-codec.js +36 -0
  79. package/dist/license.d.ts +14 -0
  80. package/dist/license.d.ts.map +1 -0
  81. package/dist/license.js +63 -0
  82. package/dist/logger.d.ts +9 -0
  83. package/dist/logger.d.ts.map +1 -0
  84. package/dist/logger.js +78 -0
  85. package/dist/monochrome.d.ts +34 -0
  86. package/dist/monochrome.d.ts.map +1 -0
  87. package/dist/monochrome.js +613 -0
  88. package/dist/monotone.d.ts +22 -0
  89. package/dist/monotone.d.ts.map +1 -0
  90. package/dist/monotone.js +185 -0
  91. package/dist/parsers-config.d.ts +19 -0
  92. package/dist/parsers-config.d.ts.map +1 -0
  93. package/dist/parsers-config.js +271 -0
  94. package/dist/patch-terminal-dimensions.d.ts +2 -0
  95. package/dist/patch-terminal-dimensions.d.ts.map +1 -0
  96. package/dist/patch-terminal-dimensions.js +45 -0
  97. package/dist/stdin-pager.test.d.ts +2 -0
  98. package/dist/stdin-pager.test.d.ts.map +1 -0
  99. package/dist/stdin-pager.test.js +497 -0
  100. package/dist/store.d.ts +16 -0
  101. package/dist/store.d.ts.map +1 -0
  102. package/dist/store.js +48 -0
  103. package/dist/themes/github.json +247 -0
  104. package/dist/themes.d.ts +59 -0
  105. package/dist/themes.d.ts.map +1 -0
  106. package/dist/themes.js +248 -0
  107. package/dist/tree-icons.d.ts +4 -0
  108. package/dist/tree-icons.d.ts.map +1 -0
  109. package/dist/tree-icons.js +18 -0
  110. package/dist/utils.d.ts +2 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils.js +13 -0
  113. package/dist/web-utils.d.ts +56 -0
  114. package/dist/web-utils.d.ts.map +1 -0
  115. package/dist/web-utils.js +363 -0
  116. package/package.json +37 -0
  117. package/public/jetbrains-mono-nerd.ttf +0 -0
  118. package/public/jetbrains-mono-nerd.woff2 +0 -0
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dropdown.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=filter-submodule.e2e.test.d.ts.map
@@ -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"}
@@ -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"}
@@ -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
+ }
@@ -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"}