mergie-cli 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 (140) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +91 -0
  3. package/bin/mergie-dev +18 -0
  4. package/bin/mergie.ts +44 -0
  5. package/dist/web/assets/index-4bq8mkyV.css +1 -0
  6. package/dist/web/assets/index-Cp_Gdwf2.js +50 -0
  7. package/dist/web/favicon.svg +12 -0
  8. package/dist/web/index.html +14 -0
  9. package/package.json +68 -0
  10. package/src/cli/args.ts +63 -0
  11. package/src/cli/constants.ts +32 -0
  12. package/src/cli/daemonClient.ts +42 -0
  13. package/src/cli/openBrowser.ts +11 -0
  14. package/src/cli/run.ts +76 -0
  15. package/src/daemon/aiReviewTracker.ts +71 -0
  16. package/src/daemon/allComments.ts +119 -0
  17. package/src/daemon/artifactCapture.ts +47 -0
  18. package/src/daemon/buildUi.ts +54 -0
  19. package/src/daemon/chatPrompt.ts +35 -0
  20. package/src/daemon/chatSocket.ts +78 -0
  21. package/src/daemon/createRegistry.ts +569 -0
  22. package/src/daemon/githubThreads.ts +92 -0
  23. package/src/daemon/inflight.ts +49 -0
  24. package/src/daemon/postMapping.ts +94 -0
  25. package/src/daemon/registry.ts +263 -0
  26. package/src/daemon/reviewPrompt.ts +22 -0
  27. package/src/daemon/reviewService.ts +243 -0
  28. package/src/daemon/router.ts +287 -0
  29. package/src/daemon/server.ts +106 -0
  30. package/src/daemon/splitView.ts +63 -0
  31. package/src/daemon/trpc.ts +20 -0
  32. package/src/db/migrate.ts +24 -0
  33. package/src/db/repositories/aiReviews.ts +113 -0
  34. package/src/db/repositories/artifacts.ts +101 -0
  35. package/src/db/repositories/chatSessions.ts +197 -0
  36. package/src/db/repositories/comments.ts +195 -0
  37. package/src/db/repositories/githubComments.ts +166 -0
  38. package/src/db/repositories/hunkViews.ts +36 -0
  39. package/src/db/repositories/reviewedRanges.ts +75 -0
  40. package/src/db/schema.ts +97 -0
  41. package/src/domain/config.ts +137 -0
  42. package/src/domain/context.ts +32 -0
  43. package/src/domain/diff.ts +178 -0
  44. package/src/domain/entities.ts +92 -0
  45. package/src/domain/fuzzy.ts +43 -0
  46. package/src/domain/generated.ts +33 -0
  47. package/src/domain/hash.ts +0 -0
  48. package/src/domain/lockfiles.ts +20 -0
  49. package/src/domain/paths.ts +59 -0
  50. package/src/domain/ranges.ts +81 -0
  51. package/src/domain/references.ts +36 -0
  52. package/src/domain/url.ts +50 -0
  53. package/src/domain/wordDiff.ts +174 -0
  54. package/src/services/ai.ts +137 -0
  55. package/src/services/exec.ts +44 -0
  56. package/src/services/ghPr.ts +102 -0
  57. package/src/services/ghSearch.ts +135 -0
  58. package/src/services/git.ts +297 -0
  59. package/src/services/github.ts +300 -0
  60. package/src/services/symbols.ts +364 -0
  61. package/src/web/App.tsx +32 -0
  62. package/src/web/components/AiReviewIndicator.tsx +63 -0
  63. package/src/web/components/AiReviewModal.tsx +101 -0
  64. package/src/web/components/AiReviewsPanel.tsx +64 -0
  65. package/src/web/components/ChatPanel.tsx +142 -0
  66. package/src/web/components/CodePreview.tsx +63 -0
  67. package/src/web/components/CommentComposer.tsx +40 -0
  68. package/src/web/components/CommentItem.tsx +59 -0
  69. package/src/web/components/CommentsPanel.tsx +309 -0
  70. package/src/web/components/CommitRail.tsx +64 -0
  71. package/src/web/components/ConfirmButton.tsx +32 -0
  72. package/src/web/components/CopyButton.tsx +33 -0
  73. package/src/web/components/CopyIconButton.tsx +38 -0
  74. package/src/web/components/DiffFrame.tsx +133 -0
  75. package/src/web/components/DiffLines.tsx +149 -0
  76. package/src/web/components/FileFrame.tsx +45 -0
  77. package/src/web/components/FileNavigator.tsx +195 -0
  78. package/src/web/components/FileTree.tsx +45 -0
  79. package/src/web/components/FileView.tsx +53 -0
  80. package/src/web/components/GithubThreadView.tsx +56 -0
  81. package/src/web/components/HunkCard.tsx +121 -0
  82. package/src/web/components/Icons.tsx +215 -0
  83. package/src/web/components/IdentifierMenuPortal.tsx +33 -0
  84. package/src/web/components/PostMenu.tsx +63 -0
  85. package/src/web/components/PrDescription.tsx +29 -0
  86. package/src/web/components/PrLoading.tsx +45 -0
  87. package/src/web/components/PrPicker.tsx +201 -0
  88. package/src/web/components/RangeSelector.tsx +141 -0
  89. package/src/web/components/ResultsList.tsx +159 -0
  90. package/src/web/components/ReviewView.tsx +308 -0
  91. package/src/web/components/RightRail.tsx +146 -0
  92. package/src/web/components/SearchRailPanel.tsx +127 -0
  93. package/src/web/components/Switch.tsx +31 -0
  94. package/src/web/components/SwitchPrModal.tsx +28 -0
  95. package/src/web/components/SymbolLookupMenu.tsx +35 -0
  96. package/src/web/components/Toolbar.tsx +79 -0
  97. package/src/web/components/Tooltip.tsx +72 -0
  98. package/src/web/index.html +13 -0
  99. package/src/web/lib/aiReviewIndicator.ts +29 -0
  100. package/src/web/lib/anchorRow.ts +25 -0
  101. package/src/web/lib/codeSearchFetch.ts +46 -0
  102. package/src/web/lib/commentFilters.ts +36 -0
  103. package/src/web/lib/commentVisibility.ts +90 -0
  104. package/src/web/lib/composerKeys.ts +24 -0
  105. package/src/web/lib/dedupeResults.ts +37 -0
  106. package/src/web/lib/diffMarks.ts +81 -0
  107. package/src/web/lib/fileStatus.ts +12 -0
  108. package/src/web/lib/filterCodeResults.ts +42 -0
  109. package/src/web/lib/filterPrs.ts +38 -0
  110. package/src/web/lib/highlight.ts +40 -0
  111. package/src/web/lib/identifierMenu.ts +41 -0
  112. package/src/web/lib/lineSelection.ts +48 -0
  113. package/src/web/lib/navRouting.ts +40 -0
  114. package/src/web/lib/navStack.ts +109 -0
  115. package/src/web/lib/persistedFlag.ts +43 -0
  116. package/src/web/lib/prPickerModel.ts +27 -0
  117. package/src/web/lib/railState.ts +19 -0
  118. package/src/web/lib/rangeCoverage.ts +27 -0
  119. package/src/web/lib/rangeMap.ts +42 -0
  120. package/src/web/lib/resultCountLabel.ts +11 -0
  121. package/src/web/lib/reviewProgress.ts +26 -0
  122. package/src/web/lib/searchInputsKey.ts +35 -0
  123. package/src/web/lib/searchToken.ts +25 -0
  124. package/src/web/lib/splitSide.ts +13 -0
  125. package/src/web/lib/time.ts +9 -0
  126. package/src/web/lib/togglePrefs.ts +81 -0
  127. package/src/web/lib/useEscToClose.ts +17 -0
  128. package/src/web/lib/useIdentifierMenu.ts +77 -0
  129. package/src/web/lib/useNavLookup.ts +74 -0
  130. package/src/web/lib/usePageTitle.ts +15 -0
  131. package/src/web/lib/visibleFiles.ts +52 -0
  132. package/src/web/main.tsx +24 -0
  133. package/src/web/public/favicon.svg +12 -0
  134. package/src/web/state/useChat.ts +181 -0
  135. package/src/web/state/useCodeSearch.ts +198 -0
  136. package/src/web/state/useReview.ts +138 -0
  137. package/src/web/styles.css +1020 -0
  138. package/src/web/trpc.ts +5 -0
  139. package/tsconfig.json +27 -0
  140. package/vite.config.ts +29 -0
@@ -0,0 +1,146 @@
1
+ import { useEffect } from "react";
2
+ import { trpc } from "../trpc.ts";
3
+ import { nextRailTab, type RailTab } from "@/web/lib/railState.ts";
4
+ import { CommentsPanel } from "./CommentsPanel.tsx";
5
+ import { AiReviewsPanel } from "./AiReviewsPanel.tsx";
6
+ import { PrDescription } from "./PrDescription.tsx";
7
+ import { SearchRailPanel } from "./SearchRailPanel.tsx";
8
+ import type { FileTarget } from "./FileView.tsx";
9
+ import { Tooltip } from "./Tooltip.tsx";
10
+ import {
11
+ CommentIcon, SparkleIcon, DocumentIcon, SearchIcon, CloseIcon,
12
+ } from "./Icons.tsx";
13
+ import type { FileView } from "@/daemon/reviewService.ts";
14
+ import type { CodeSearchState } from "../state/useCodeSearch.ts";
15
+
16
+ /** Static presentation for each rail tab: icon, label, tooltip. */
17
+ interface RailTabMeta {
18
+ /** The rail tab this metadata describes. */
19
+ tab: RailTab;
20
+ /** Human-readable panel title + tooltip / aria-label. */
21
+ label: string;
22
+ /** The inline-SVG icon component for the rail button. */
23
+ Icon: (props: { size?: number }) => React.JSX.Element;
24
+ }
25
+
26
+ /** Order + presentation of the rail tabs (top → bottom). */
27
+ const TAB_META: readonly RailTabMeta[] = [
28
+ { tab: "comments", label: "Comments", Icon: CommentIcon },
29
+ { tab: "reviews", label: "AI reviews", Icon: SparkleIcon },
30
+ { tab: "description", label: "PR description", Icon: DocumentIcon },
31
+ { tab: "search", label: "Search", Icon: SearchIcon },
32
+ ] as const;
33
+
34
+ /**
35
+ * The right icon rail plus its expandable sidebar. The rail (three icon
36
+ * buttons) is always visible, pinned to the right edge. Clicking an icon
37
+ * expands the sidebar to its left showing that surface and pushes the diff
38
+ * aside; clicking the active icon again — or pressing Esc — collapses it.
39
+ *
40
+ * All three surfaces stay MOUNTED whenever the sidebar is open; the inactive
41
+ * ones are hidden via `hidden`, so each keeps its own scroll position and
42
+ * internal state across tab switches within a session.
43
+ */
44
+ export function RightRail(props: {
45
+ /** PR id. */
46
+ prId: string;
47
+ /** The active rail tab, or null when the sidebar is collapsed. */
48
+ active: RailTab | null;
49
+ /** Set the active tab (null collapses). */
50
+ onActiveChange: (tab: RailTab | null) => void;
51
+ /** All files/hunks in the selected range (before view toggles). */
52
+ rangeFiles: FileView[];
53
+ /** The subset currently rendered on screen (after view toggles). */
54
+ renderedFiles: FileView[];
55
+ /** Baseline SHA for the comments panel's "view in context" link. */
56
+ baselineSha: string;
57
+ /** Force a hunk visible so a jumped-to comment can be scrolled into view. */
58
+ onReveal: (hunkHash: string) => void;
59
+ /** The PR body markdown for the description panel. */
60
+ prBody: string;
61
+ /** The code-search state hosting the Search tab. */
62
+ codeSearch: CodeSearchState;
63
+ /** Open a result location in the file navigator (owned by ReviewView). */
64
+ onOpenFile: (target: FileTarget) => void;
65
+ }): React.JSX.Element {
66
+ const { prId, active, onActiveChange, rangeFiles, renderedFiles, baselineSha, onReveal, prBody, codeSearch, onOpenFile } = props;
67
+
68
+ // Live total comment count for the Comments icon badge. Shares react-query's
69
+ // cache with the panel's own listAllComments query, so the two always agree.
70
+ const commentCount: number = trpc.listAllComments.useQuery({ id: prId }).data?.length ?? 0;
71
+
72
+ // Esc collapses the sidebar when a tab is open. The comments panel dismisses
73
+ // its own out-of-range prompt first (capture-phase handler stops propagation).
74
+ useEffect(() => {
75
+ if (active === null) return;
76
+ const onKey = (e: KeyboardEvent): void => {
77
+ if (e.key === "Escape") onActiveChange(null);
78
+ };
79
+ window.addEventListener("keydown", onKey);
80
+ return () => window.removeEventListener("keydown", onKey);
81
+ }, [active, onActiveChange]);
82
+
83
+ const activeLabel: string = TAB_META.find((m) => m.tab === active)?.label ?? "";
84
+
85
+ return (
86
+ <>
87
+ {/* The sidebar stays MOUNTED across collapse (hidden, not unmounted) so
88
+ every panel's scroll position and internal state survive a
89
+ collapse→reopen within the session. */}
90
+ <aside className="rail-sidebar" aria-label={activeLabel} hidden={active === null}>
91
+ <header className="rail-sidebar-header">
92
+ <strong>{activeLabel}</strong>
93
+ <button
94
+ type="button"
95
+ className="symbol-panel-close"
96
+ onClick={() => onActiveChange(null)}
97
+ title="Close (Esc)"
98
+ aria-label="Close"
99
+ >
100
+ <CloseIcon size={16} />
101
+ </button>
102
+ </header>
103
+ <div className="rail-sidebar-body">
104
+ <div className="rail-panel" hidden={active !== "comments"}>
105
+ <CommentsPanel
106
+ prId={prId}
107
+ rangeFiles={rangeFiles}
108
+ renderedFiles={renderedFiles}
109
+ baselineSha={baselineSha}
110
+ onReveal={onReveal}
111
+ />
112
+ </div>
113
+ <div className="rail-panel" hidden={active !== "reviews"}>
114
+ <AiReviewsPanel prId={prId} />
115
+ </div>
116
+ <div className="rail-panel" hidden={active !== "description"}>
117
+ <PrDescription body={prBody} />
118
+ </div>
119
+ <div className="rail-panel" hidden={active !== "search"}>
120
+ <SearchRailPanel state={codeSearch} onOpenFile={onOpenFile} />
121
+ </div>
122
+ </div>
123
+ </aside>
124
+ <nav className="right-rail" aria-label="Review panels">
125
+ {TAB_META.map(({ tab, label, Icon }) => (
126
+ <div key={tab} className="rail-item">
127
+ <Tooltip label={label} placement="left">
128
+ <button
129
+ type="button"
130
+ className={active === tab ? "rail-btn active" : "rail-btn"}
131
+ aria-pressed={active === tab}
132
+ aria-label={label}
133
+ onClick={() => onActiveChange(nextRailTab(active, tab))}
134
+ >
135
+ <Icon size={20} />
136
+ {tab === "comments" && commentCount > 0 && (
137
+ <span className="rail-badge" aria-hidden="true">{commentCount}</span>
138
+ )}
139
+ </button>
140
+ </Tooltip>
141
+ </div>
142
+ ))}
143
+ </nav>
144
+ </>
145
+ );
146
+ }
@@ -0,0 +1,127 @@
1
+ import { ResultsList, type OpenLocation, type ResultsHeader } from "./ResultsList.tsx";
2
+ import { Tooltip } from "./Tooltip.tsx";
3
+ import type { FileTarget } from "./FileView.tsx";
4
+ import type { CodeSearchState, RunSnapshot } from "../state/useCodeSearch.ts";
5
+
6
+ /** Human label for a run's lookup, for the results header. */
7
+ function opLabel(run: Pick<RunSnapshot, "mode" | "symbolAction">): string {
8
+ if (run.mode === "general") return "Search";
9
+ return run.symbolAction === "usages" ? "Usages" : "Definition";
10
+ }
11
+
12
+ /** One segmented pill, wrapped in the app's hover Tooltip (matches the icon rail). */
13
+ function Pill(props: { label: string; tip: string; active: boolean; onClick: () => void }): React.JSX.Element {
14
+ const { label, tip, active, onClick } = props;
15
+ return (
16
+ <Tooltip label={tip} placement="bottom">
17
+ <button type="button" className={active ? "segmented-item active" : "segmented-item"} aria-pressed={active} onClick={onClick}>{label}</button>
18
+ </Tooltip>
19
+ );
20
+ }
21
+
22
+ /** General|Symbol mode segmented toggle. */
23
+ function ModeToggle(props: { state: CodeSearchState }): React.JSX.Element {
24
+ const { state } = props;
25
+ return (
26
+ <div className="segmented" role="group" aria-label="Search mode">
27
+ <Pill label="General" tip="Plain-text search across the code (ripgrep)." active={state.mode === "general"} onClick={() => state.setMode("general")} />
28
+ <Pill label="Symbol" tip="Look up a symbol semantically — its definition or usages." active={state.mode === "symbol"} onClick={() => state.setMode("symbol")} />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ /** Definition|Usages segmented toggle (symbol mode). */
34
+ function ActionToggle(props: { state: CodeSearchState }): React.JSX.Element {
35
+ const { state } = props;
36
+ return (
37
+ <div className="segmented" role="group" aria-label="Symbol lookup">
38
+ <Pill label="Definition" tip="Find where the symbol is defined (lists all matching definitions)." active={state.symbolAction === "definition"} onClick={() => state.setSymbolAction("definition")} />
39
+ <Pill label="Usages" tip="Find where the symbol is referenced. Best-effort — some references may be missed." active={state.symbolAction === "usages"} onClick={() => state.setSymbolAction("usages")} />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ /** Head|Base segmented toggle. */
45
+ function SideToggle(props: { state: CodeSearchState }): React.JSX.Element {
46
+ const { state } = props;
47
+ return (
48
+ <div className="segmented" role="group" aria-label="Checkout side">
49
+ <Pill label="Head" tip="Search the head version — the code at the end of the selected commit range." active={state.side === "head"} onClick={() => state.setSide("head")} />
50
+ <Pill label="Base" tip="Search the base version — the code at the start of the selected commit range." active={state.side === "base"} onClick={() => state.setSide("base")} />
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * The right-rail "Search" tab: a query input, a General|Symbol mode toggle,
57
+ * mode-specific options (case/regex for general; Definition|Usages for symbol),
58
+ * a Head|Base checkout toggle, and the shared {@link ResultsList}. Runs on
59
+ * Enter. "View file" opens the file in the shared file navigator (owned by
60
+ * ReviewView).
61
+ *
62
+ * @param props.state - The code-search state from `useCodeSearch`.
63
+ * @param props.onOpenFile - Open a result location in the file navigator.
64
+ */
65
+ export function SearchRailPanel(props: { state: CodeSearchState; onOpenFile: (target: FileTarget) => void }): React.JSX.Element {
66
+ const { state, onOpenFile } = props;
67
+
68
+ // The header reflects the last run (frozen), not the live controls, so editing
69
+ // the query/mode does not change it until Run is pressed.
70
+ const run: RunSnapshot | null = state.lastRun;
71
+ const header: ResultsHeader = run
72
+ ? { op: opLabel(run), term: run.term, scope: run.side, scopeFile: run.scopeFile }
73
+ : { op: opLabel(state), term: "", scope: state.side };
74
+ const openFile = (loc: OpenLocation): void => onOpenFile({ path: loc.path, sha: loc.sha, line: loc.line });
75
+
76
+ return (
77
+ <div className="search-rail-panel">
78
+ <div className="search-rail-controls">
79
+ <input
80
+ type="text"
81
+ className="search-rail-query"
82
+ placeholder={state.mode === "symbol" ? "Symbol name…" : "Search text…"}
83
+ value={state.query}
84
+ onChange={(e) => state.setQuery(e.target.value)}
85
+ onKeyDown={(e) => { if (e.key === "Enter") state.runFromRail(); }}
86
+ />
87
+ <div className="search-rail-row">
88
+ <ModeToggle state={state} />
89
+ <SideToggle state={state} />
90
+ </div>
91
+ {state.mode === "general" ? (
92
+ <div className="search-rail-row">
93
+ <label className="results-toggle"><input type="checkbox" checked={state.caseSensitive} onChange={(e) => state.setCaseSensitive(e.target.checked)} /> Case sensitive</label>
94
+ <label className="results-toggle"><input type="checkbox" checked={state.regex} onChange={(e) => state.setRegex(e.target.checked)} /> Regex</label>
95
+ </div>
96
+ ) : (
97
+ <div className="search-rail-row"><ActionToggle state={state} /></div>
98
+ )}
99
+ <div className="search-rail-run">
100
+ <button
101
+ type="button"
102
+ className={state.dirty ? "btn btn-accent btn-sm" : "btn btn-sm"}
103
+ onClick={state.runFromRail}
104
+ disabled={state.query.trim() === "" || !state.dirty}
105
+ >
106
+ Run
107
+ </button>
108
+ {state.dirty && state.results.length > 0 && (
109
+ <span className="search-rail-stale">Inputs changed — press Run to update</span>
110
+ )}
111
+ </div>
112
+ </div>
113
+ <ResultsList
114
+ results={state.results}
115
+ loading={state.loading}
116
+ error={state.error}
117
+ filters={state.filters}
118
+ onFilters={state.setFilters}
119
+ sha={state.resultSha}
120
+ header={header}
121
+ onOpen={openFile}
122
+ onClose={() => { /* rail Esc collapse is owned by RightRail */ }}
123
+ onBroaden={state.broaden}
124
+ />
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A labelled iOS-style on/off toggle switch. The whole row (label + switch) is
3
+ * one click target: it is a `<label>` wrapping a visually-hidden native
4
+ * checkbox (given `role="switch"` so it is announced as a switch), so it stays
5
+ * keyboard-operable (Space toggles), shows the shared focus ring on keyboard
6
+ * focus, and clicking the label toggles it — all for free from the native
7
+ * control. The visible track + knob are driven purely by the checkbox state.
8
+ */
9
+ export function Switch(props: {
10
+ /** Whether the switch is on. */
11
+ checked: boolean;
12
+ /** Called with the new on/off state when toggled. */
13
+ onChange: (checked: boolean) => void;
14
+ /** Visible label, also the control's accessible name. */
15
+ label: string;
16
+ }): React.JSX.Element {
17
+ const { checked, onChange, label } = props;
18
+ return (
19
+ <label className="switch-row">
20
+ <span className="switch-label">{label}</span>
21
+ <input
22
+ type="checkbox"
23
+ role="switch"
24
+ className="switch-input"
25
+ checked={checked}
26
+ onChange={(e) => onChange(e.target.checked)}
27
+ />
28
+ <span className="switch-track" aria-hidden="true"><span className="switch-knob" /></span>
29
+ </label>
30
+ );
31
+ }
@@ -0,0 +1,28 @@
1
+ import { PrPicker } from "./PrPicker.tsx";
2
+ import { useEscToClose } from "@/web/lib/useEscToClose.ts";
3
+ import { CloseIcon } from "./Icons.tsx";
4
+
5
+ /**
6
+ * The in-review "Switch PR" overlay: the same {@link PrPicker} shown on the
7
+ * home screen, in a modal. Closes on Escape, the close button, or a click on
8
+ * the backdrop. The current PR is marked (and not clickable) inside the picker.
9
+ */
10
+ export function SwitchPrModal(props: { currentPrId: string; onClose: () => void }): React.JSX.Element {
11
+ useEscToClose(props.onClose);
12
+ return (
13
+ <div className="modal-overlay" onClick={props.onClose}>
14
+ <div className="modal picker-modal" onClick={(e) => e.stopPropagation()}>
15
+ <header className="modal-header">
16
+ <strong>Switch pull request</strong>
17
+ <span className="modal-header-spacer" />
18
+ <button type="button" className="modal-close" onClick={props.onClose} aria-label="Close">
19
+ <CloseIcon size={16} />
20
+ </button>
21
+ </header>
22
+ <div className="picker-modal-body">
23
+ <PrPicker currentPrId={props.currentPrId} />
24
+ </div>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,35 @@
1
+ import { useState } from "react";
2
+ import { CloseIcon } from "./Icons.tsx";
3
+ import type { MenuOp, SearchSide } from "../state/useCodeSearch.ts";
4
+
5
+ /**
6
+ * A small floating menu shown when a symbol is selected in the diff, offering
7
+ * the three lookup actions and a head/base checkout toggle. The side toggle
8
+ * starts on `defaultSide` — which reflects the column/line the symbol was
9
+ * selected in (before → base, after → head) — and stays overridable.
10
+ */
11
+ export function SymbolLookupMenu(props: {
12
+ term: string;
13
+ x: number;
14
+ y: number;
15
+ defaultSide: SearchSide;
16
+ onPick: (op: MenuOp, side: SearchSide) => void;
17
+ onClose: () => void;
18
+ }): React.JSX.Element {
19
+ const { term, x, y, defaultSide, onPick, onClose } = props;
20
+ const [side, setSide] = useState<SearchSide>(defaultSide);
21
+
22
+ return (
23
+ <div className="symbol-menu" style={{ left: x, top: y }}>
24
+ <code className="symbol-menu-term">{term}</code>
25
+ <span className="symbol-menu-side">
26
+ <button type="button" className={side === "head" ? "active" : ""} onClick={() => setSide("head")}>Head</button>
27
+ <button type="button" className={side === "base" ? "active" : ""} onClick={() => setSide("base")}>Base</button>
28
+ </span>
29
+ <button type="button" className="btn btn-sm" onClick={() => onPick("definition", side)}>Definition</button>
30
+ <button type="button" className="btn btn-sm" onClick={() => onPick("usages", side)}>Usages</button>
31
+ <button type="button" className="btn btn-sm" onClick={() => onPick("search", side)}>Search</button>
32
+ <button type="button" className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close"><CloseIcon size={14} /></button>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Switch } from "./Switch.tsx";
3
+ import { Tooltip } from "./Tooltip.tsx";
4
+ import { useEscToClose } from "@/web/lib/useEscToClose.ts";
5
+ import { ChevronDownIcon } from "./Icons.tsx";
6
+ import type { VisibilityToggles } from "@/web/lib/visibleFiles.ts";
7
+
8
+ /** Tooltip explaining that viewed-progress is scoped to each whitespace mode. */
9
+ const HIDE_WHITESPACE_HINT =
10
+ "Hide changes that are only whitespace (re-diffs ignoring spacing). " +
11
+ "Viewed-hunk progress is tracked separately for each mode, so hunks may " +
12
+ "reappear as un-viewed when you turn this off — your original marks return unchanged.";
13
+
14
+ /**
15
+ * The "View" group of visibility filters. To stay compact now that there are
16
+ * four filters, the two most-used ones ("Hide viewed files" and "Hide
17
+ * whitespace changes") are pinned as switches, and the remaining two live in a
18
+ * "More filters" popover whose button carries a count badge when any of them
19
+ * are active.
20
+ */
21
+ export function Toolbar(props: {
22
+ toggles: VisibilityToggles;
23
+ onChange: (t: VisibilityToggles) => void;
24
+ hideWhitespace: boolean;
25
+ onHideWhitespaceChange: (value: boolean) => void;
26
+ }): React.JSX.Element {
27
+ const { toggles, onChange, hideWhitespace, onHideWhitespaceChange } = props;
28
+ const [moreOpen, setMoreOpen] = useState(false);
29
+ const rootRef = useRef<HTMLDivElement>(null);
30
+ const closeMore = useCallback(() => setMoreOpen((o) => (o ? false : o)), []);
31
+ useEscToClose(closeMore);
32
+
33
+ // Close the popover on any pointer press outside it (bound only while open).
34
+ useEffect(() => {
35
+ if (!moreOpen) return;
36
+ const onPointerDown = (e: PointerEvent): void => {
37
+ const target: Node | null = e.target instanceof Node ? e.target : null;
38
+ if (target && rootRef.current?.contains(target)) return;
39
+ setMoreOpen(false);
40
+ };
41
+ document.addEventListener("pointerdown", onPointerDown);
42
+ return () => document.removeEventListener("pointerdown", onPointerDown);
43
+ }, [moreOpen]);
44
+
45
+ const toggleRow = (key: keyof VisibilityToggles, label: string) => (
46
+ <Switch label={label} checked={toggles[key]} onChange={(checked) => onChange({ ...toggles, [key]: checked })} />
47
+ );
48
+
49
+ // Filters hidden behind "More"; the badge counts how many are currently on.
50
+ const hiddenActive: number = [toggles.hideViewedHunks, toggles.hideLockFiles].filter(Boolean).length;
51
+
52
+ return (
53
+ <div className="toolbar" ref={rootRef}>
54
+ {toggleRow("hideViewedFiles", "Hide viewed files")}
55
+ <Tooltip label={HIDE_WHITESPACE_HINT} placement="bottom" className="tooltip-block">
56
+ <Switch label="Hide whitespace changes" checked={hideWhitespace} onChange={onHideWhitespaceChange} />
57
+ </Tooltip>
58
+
59
+ <div className="more-filters">
60
+ <button
61
+ type="button"
62
+ className="more-filters-btn"
63
+ aria-expanded={moreOpen}
64
+ onClick={() => setMoreOpen((o) => !o)}
65
+ >
66
+ <span>More filters</span>
67
+ {hiddenActive > 0 && <span className="more-filters-badge">{hiddenActive}</span>}
68
+ <ChevronDownIcon size={14} />
69
+ </button>
70
+ {moreOpen && (
71
+ <div className="more-filters-pop">
72
+ {toggleRow("hideViewedHunks", "Hide viewed hunks")}
73
+ {toggleRow("hideLockFiles", "Hide lock files")}
74
+ </div>
75
+ )}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,72 @@
1
+ import { useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ /** Which side of the trigger the tooltip is placed on. */
5
+ export type TooltipPlacement = "left" | "bottom";
6
+
7
+ /** A resolved on-screen position for the floating tooltip bubble. */
8
+ interface TooltipPos {
9
+ /** Viewport x (px) — the bubble's left or right edge depending on placement. */
10
+ x: number;
11
+ /** Viewport y (px) — the bubble's vertical anchor. */
12
+ y: number;
13
+ }
14
+
15
+ /** Gap in px between the trigger and the tooltip bubble. */
16
+ const GAP = 8;
17
+
18
+ /**
19
+ * A near-instant hover/focus tooltip. Wraps a single trigger element and renders
20
+ * the label in a small pill that is **portaled to `document.body`**, so it is
21
+ * never clipped by an ancestor's `clip-path`/`overflow` (e.g. the hunk card).
22
+ * The tooltip appears on `mouseenter`/`focus` with no show-delay (only a tiny
23
+ * CSS fade) and hides immediately on `mouseleave`/`blur`. The trigger keeps its
24
+ * own `aria-label`; the visual bubble is `aria-hidden`.
25
+ */
26
+ export function Tooltip(props: {
27
+ /** The text shown in the tooltip bubble. */
28
+ label: string;
29
+ /** Which side of the trigger to place the bubble on. Defaults to "bottom". */
30
+ placement?: TooltipPlacement;
31
+ /** Extra class(es) for the wrapper span (e.g. to make it a full-width block). */
32
+ className?: string;
33
+ /** The single trigger element (a button). */
34
+ children: React.ReactElement;
35
+ }): React.JSX.Element {
36
+ const placement: TooltipPlacement = props.placement ?? "bottom";
37
+ const wrapRef = useRef<HTMLSpanElement>(null);
38
+ const [pos, setPos] = useState<TooltipPos | null>(null);
39
+
40
+ const show = (): void => {
41
+ const trigger = wrapRef.current?.firstElementChild;
42
+ if (!trigger) return;
43
+ const r = trigger.getBoundingClientRect();
44
+ setPos(placement === "left"
45
+ ? { x: r.left - GAP, y: r.top + r.height / 2 }
46
+ : { x: r.left + r.width / 2, y: r.bottom + GAP });
47
+ };
48
+ const hide = (): void => setPos(null);
49
+
50
+ return (
51
+ <span
52
+ ref={wrapRef}
53
+ className={props.className ? `tooltip-wrap ${props.className}` : "tooltip-wrap"}
54
+ onMouseEnter={show}
55
+ onMouseLeave={hide}
56
+ onFocusCapture={show}
57
+ onBlurCapture={hide}
58
+ >
59
+ {props.children}
60
+ {pos !== null && createPortal(
61
+ <span
62
+ className={`tooltip tooltip-${placement}`}
63
+ aria-hidden="true"
64
+ style={{ left: pos.x, top: pos.y }}
65
+ >
66
+ {props.label}
67
+ </span>,
68
+ document.body,
69
+ )}
70
+ </span>
71
+ );
72
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>mergie</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,29 @@
1
+ import type { AiReviewState, AiReviewStatus } from "@/daemon/aiReviewTracker.ts";
2
+
3
+ /** What the header indicator should display, derived from raw statuses. */
4
+ export interface AiReviewSummary {
5
+ /** Which state the indicator reflects. */
6
+ state: AiReviewState;
7
+ /** How many reviews are currently running (0 when not in a running state). */
8
+ runningCount: number;
9
+ /** The status to act on (open its range) when the indicator is clicked. */
10
+ primary: AiReviewStatus;
11
+ }
12
+
13
+ /**
14
+ * Reduce the per-range AI-review statuses to the one thing the header indicator
15
+ * should show. Priority: any running review wins (with a count of how many are
16
+ * running); otherwise a completed (done) review is surfaced as clickable-ready;
17
+ * otherwise a failed one. Returns null when there is nothing to show.
18
+ */
19
+ export function summariseAiReviewStatuses(statuses: AiReviewStatus[]): AiReviewSummary | null {
20
+ const running = statuses.filter((s) => s.state === "running");
21
+ if (running.length > 0 && running[0]) {
22
+ return { state: "running", runningCount: running.length, primary: running[0] };
23
+ }
24
+ const done = statuses.find((s) => s.state === "done");
25
+ if (done) return { state: "done", runningCount: 0, primary: done };
26
+ const failed = statuses.find((s) => s.state === "failed");
27
+ if (failed) return { state: "failed", runningCount: 0, primary: failed };
28
+ return null;
29
+ }
@@ -0,0 +1,25 @@
1
+ import type { SplitRow } from "@/daemon/splitView.ts";
2
+
3
+ /** Whether a split row represents a change (either side is not a context cell). */
4
+ function isChangedRow(row: SplitRow): boolean {
5
+ return row.left.kind !== "ctx" || row.right.kind !== "ctx";
6
+ }
7
+
8
+ /**
9
+ * Index of the first changed row (an addition/deletion, i.e. a row where a side
10
+ * is `add`/`del`/`empty` rather than `ctx`) at or after `startIdx`. Used to move
11
+ * the full-file modal's anchor highlight off the context line it was opened at
12
+ * (e.g. a `}`) and onto the real +/- line just below.
13
+ *
14
+ * Falls back to `startIdx` unchanged when there is no changed row at/after it —
15
+ * including a negative `startIdx` (no anchor) — so the caller never jumps to the
16
+ * top or crashes.
17
+ */
18
+ export function firstChangedIndexFrom(rows: SplitRow[], startIdx: number): number {
19
+ if (startIdx < 0) return startIdx;
20
+ for (let i = startIdx; i < rows.length; i++) {
21
+ const row = rows[i];
22
+ if (row && isChangedRow(row)) return i;
23
+ }
24
+ return startIdx;
25
+ }
@@ -0,0 +1,46 @@
1
+ import type { trpc } from "@/web/trpc.ts";
2
+ import { BadRegexError, type CodeResult } from "@/services/symbols.ts";
3
+ import type { SearchMode, SymbolAction, SearchSide } from "@/web/state/useCodeSearch.ts";
4
+
5
+ /** The tRPC utils object (`trpc.useUtils()`), used for imperative fetches. */
6
+ export type TrpcUtils = ReturnType<typeof trpc.useUtils>;
7
+
8
+ /** Parameters resolved for one lookup run. */
9
+ export interface RunParams {
10
+ /** Search mode to run in. */
11
+ mode: SearchMode;
12
+ /** Symbol lookup (used only in symbol mode). */
13
+ symbolAction: SymbolAction;
14
+ /** The term/query to run. */
15
+ term: string;
16
+ /** The checkout side to run against. */
17
+ side: SearchSide;
18
+ /** Case-sensitive (general mode). */
19
+ caseSensitive: boolean;
20
+ /** Regex (general mode). */
21
+ regex: boolean;
22
+ /** Optional file scope hint for symbol lookups. */
23
+ file?: string;
24
+ }
25
+
26
+ /**
27
+ * Fetch results for one resolved run via the tRPC utils. The single place the
28
+ * symbol/search tRPC procedures are called, so the rail and the navigator share
29
+ * one fetch path.
30
+ */
31
+ export async function fetchResults(utils: TrpcUtils, prId: string, sha: string, p: RunParams): Promise<CodeResult[]> {
32
+ if (p.mode === "general") {
33
+ return utils.symbolSearch.fetch({ id: prId, word: p.term, sha, caseSensitive: p.caseSensitive, regex: p.regex });
34
+ }
35
+ if (p.symbolAction === "usages") {
36
+ return utils.symbolUsages.fetch({ id: prId, symbol: p.term, sha, file: p.file });
37
+ }
38
+ return utils.symbolDefinition.fetch({ id: prId, symbol: p.term, sha, file: p.file });
39
+ }
40
+
41
+ /** A human message for a lookup error (bad regex vs generic failure). */
42
+ export function errorMessage(err: unknown): string {
43
+ if (err instanceof BadRegexError) return err.message;
44
+ if (err instanceof Error && /regex|pattern/i.test(err.message)) return "Invalid search pattern.";
45
+ return "Search failed. Please try again.";
46
+ }