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,43 @@
1
+ import { useState } from "react";
2
+
3
+ /**
4
+ * The subset of the browser `Storage` API this module needs. Declared as an
5
+ * interface so tests can supply an in-memory store; `window.localStorage`
6
+ * satisfies it structurally.
7
+ */
8
+ export interface FlagStore {
9
+ /** Read a stored string value, or `null` if absent. */
10
+ getItem(key: string): string | null;
11
+ /** Persist a string value. */
12
+ setItem(key: string, value: string): void;
13
+ }
14
+
15
+ /**
16
+ * Read a persisted boolean flag. Only the exact strings `"true"`/`"false"` are
17
+ * recognised; anything absent or malformed returns `fallback`.
18
+ */
19
+ export function readFlag(store: FlagStore, key: string, fallback: boolean): boolean {
20
+ const raw: string | null = store.getItem(key);
21
+ if (raw === "true") return true;
22
+ if (raw === "false") return false;
23
+ return fallback;
24
+ }
25
+
26
+ /** Persist a boolean flag. */
27
+ export function writeFlag(store: FlagStore, key: string, value: boolean): void {
28
+ store.setItem(key, value ? "true" : "false");
29
+ }
30
+
31
+ /**
32
+ * React hook: a boolean flag persisted in local storage under `key`, so it
33
+ * survives reloads and navigation. Used for global layout preferences (e.g.
34
+ * whether the left sidebar is collapsed) that are not scoped to a single PR.
35
+ */
36
+ export function usePersistedFlag(key: string, fallback: boolean): [boolean, (value: boolean) => void] {
37
+ const [flag, setFlag] = useState<boolean>(() => readFlag(window.localStorage, key, fallback));
38
+ const update = (value: boolean): void => {
39
+ writeFlag(window.localStorage, key, value);
40
+ setFlag(value);
41
+ };
42
+ return [flag, update];
43
+ }
@@ -0,0 +1,27 @@
1
+ /** The identity fields shared by loaded PRs and GitHub search results. */
2
+ export interface PrIdentity {
3
+ /** Repository owner / organisation. */
4
+ owner: string;
5
+ /** Repository name. */
6
+ repo: string;
7
+ /** Pull request number. */
8
+ number: number;
9
+ }
10
+
11
+ /** A stable `owner/repo#number` key for deduping across sources. */
12
+ export function prKey(pr: PrIdentity): string {
13
+ return `${pr.owner}/${pr.repo}#${pr.number}`;
14
+ }
15
+
16
+ /**
17
+ * Remove search results that already appear in the loaded set (matched by
18
+ * {@link prKey}), so the "From GitHub" section never repeats a PR shown under
19
+ * "Recently reviewed". Inputs are not mutated.
20
+ */
21
+ export function excludeLoaded<T extends PrIdentity>(
22
+ search: readonly T[],
23
+ loaded: readonly PrIdentity[],
24
+ ): T[] {
25
+ const loadedKeys = new Set<string>(loaded.map(prKey));
26
+ return search.filter((pr) => !loadedKeys.has(prKey(pr)));
27
+ }
@@ -0,0 +1,19 @@
1
+ /** The surfaces the right rail can host, in top-to-bottom order. */
2
+ export type RailTab = "comments" | "reviews" | "description" | "search";
3
+
4
+ /** All rail tabs in the order they appear in the rail. */
5
+ export const RAIL_TABS: readonly RailTab[] = ["comments", "reviews", "description", "search"] as const;
6
+
7
+ /**
8
+ * Compute the next active rail tab after clicking a rail icon.
9
+ *
10
+ * @param current - The currently active tab, or null when the sidebar is
11
+ * collapsed.
12
+ * @param clicked - The tab whose icon was clicked.
13
+ * @returns The tab to activate, or null to collapse. Clicking the already-active
14
+ * tab collapses the sidebar; clicking any other tab switches to it (or opens
15
+ * the sidebar when it was collapsed).
16
+ */
17
+ export function nextRailTab(current: RailTab | null, clicked: RailTab): RailTab | null {
18
+ return current === clicked ? null : clicked;
19
+ }
@@ -0,0 +1,27 @@
1
+ /** Inputs for computing a range-coverage label. */
2
+ export interface RangeCoverage {
3
+ /** Index of the first included commit (0-based, oldest = 0). */
4
+ fromIndex: number;
5
+ /** Index of the last included commit (0-based). */
6
+ toIndex: number;
7
+ /** Total number of commits in the PR. */
8
+ total: number;
9
+ }
10
+
11
+ /** Pluralise "commit" for a count. */
12
+ function commitWord(n: number): string {
13
+ return n === 1 ? "commit" : "commits";
14
+ }
15
+
16
+ /**
17
+ * A compact label describing how much of the PR a commit selection covers, for
18
+ * the range-selector pill. When the selection spans the whole PR
19
+ * (`fromIndex === 0` and `toIndex === total - 1`) it reads "All {total}
20
+ * commit(s)"; otherwise "{K} of {total} commits" where K is the selected count.
21
+ */
22
+ export function rangeCoverageLabel(cov: RangeCoverage): string {
23
+ const selected: number = cov.toIndex - cov.fromIndex + 1;
24
+ const isFull: boolean = cov.fromIndex === 0 && cov.toIndex === cov.total - 1;
25
+ if (isFull) return `All ${cov.total} ${commitWord(cov.total)}`;
26
+ return `${selected} of ${cov.total} ${commitWord(cov.total)}`;
27
+ }
@@ -0,0 +1,42 @@
1
+ /** An inclusive commit selection: first and last commit index to include. */
2
+ export interface InclusiveSel {
3
+ /** Index (in the commits array) of the first included commit. */
4
+ fromIndex: number;
5
+ /** Index of the last included commit. */
6
+ toIndex: number;
7
+ }
8
+
9
+ /** An exclusive commit range (diff = start→end, start excluded). */
10
+ export interface ExclusiveRange {
11
+ /** Baseline SHA (excluded). */
12
+ start: string;
13
+ /** End SHA (included). */
14
+ end: string;
15
+ }
16
+
17
+ /** Clamp a number into [min, max]. */
18
+ function clamp(n: number, min: number, max: number): number {
19
+ return Math.max(min, Math.min(max, n));
20
+ }
21
+
22
+ /**
23
+ * Convert an exclusive range into an inclusive selection (first/last included
24
+ * commit indices) for display. The exclusive `start` is the commit *before*
25
+ * the first included commit (or the baseline).
26
+ */
27
+ export function toInclusive(range: ExclusiveRange, commits: string[], baselineSha: string): InclusiveSel {
28
+ const fromIndex: number = range.start === baselineSha ? 0 : commits.indexOf(range.start) + 1;
29
+ const toIndex: number = commits.indexOf(range.end);
30
+ return { fromIndex: clamp(fromIndex, 0, commits.length - 1), toIndex: clamp(toIndex, 0, commits.length - 1) };
31
+ }
32
+
33
+ /**
34
+ * Convert an inclusive selection back into an exclusive range for the API.
35
+ * `fromIndex` above `toIndex` is clamped to a single commit.
36
+ */
37
+ export function toRange(sel: InclusiveSel, commits: string[], baselineSha: string): ExclusiveRange {
38
+ const to: number = clamp(sel.toIndex, 0, commits.length - 1);
39
+ const from: number = clamp(sel.fromIndex, 0, to);
40
+ const start: string = from === 0 ? baselineSha : (commits[from - 1] ?? baselineSha);
41
+ return { start, end: commits[to] ?? baselineSha };
42
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A short count label for a results header. When every result is shown it
3
+ * reads "N result(s)"; when a filter hides some it reads "showing X of Y".
4
+ *
5
+ * @param total - The total number of results before filtering.
6
+ * @param shown - The number of results currently shown after filtering.
7
+ */
8
+ export function resultCountLabel(total: number, shown: number): string {
9
+ if (shown < total) return `showing ${shown} of ${total}`;
10
+ return `${total} ${total === 1 ? "result" : "results"}`;
11
+ }
@@ -0,0 +1,26 @@
1
+ import type { FileView } from "@/daemon/reviewService.ts";
2
+
3
+ /** How much of the on-screen range has been reviewed, counted in hunks. */
4
+ export interface ReviewProgress {
5
+ /** Number of hunks marked viewed. */
6
+ viewed: number;
7
+ /** Total number of hunks in the range (lock/generated files included). */
8
+ total: number;
9
+ }
10
+
11
+ /**
12
+ * Count viewed hunks against every hunk in the given file list. Pure — does not
13
+ * mutate its input. Callers pass the full, unfiltered range file list so the
14
+ * total reflects the whole on-screen range regardless of visibility toggles.
15
+ */
16
+ export function reviewProgress(files: readonly FileView[]): ReviewProgress {
17
+ let viewed = 0;
18
+ let total = 0;
19
+ for (const file of files) {
20
+ for (const hunk of file.hunks) {
21
+ total += 1;
22
+ if (hunk.viewed) viewed += 1;
23
+ }
24
+ }
25
+ return { viewed, total };
26
+ }
@@ -0,0 +1,35 @@
1
+ import type { SearchMode, SymbolAction, SearchSide } from "@/web/state/useCodeSearch.ts";
2
+
3
+ /** The run-relevant search inputs whose change makes results stale. */
4
+ export interface SearchInputs {
5
+ /** General text search vs semantic symbol lookup. */
6
+ mode: SearchMode;
7
+ /** The query / symbol term (compared trimmed). */
8
+ query: string;
9
+ /** General mode: case-sensitive matching. */
10
+ caseSensitive: boolean;
11
+ /** General mode: regex matching. */
12
+ regex: boolean;
13
+ /** Symbol mode: which lookup. */
14
+ symbolAction: SymbolAction;
15
+ /** Target checkout side. */
16
+ side: SearchSide;
17
+ }
18
+
19
+ /**
20
+ * A stable string identifying a set of search inputs. Two input sets produce
21
+ * the same key iff running them would issue the same query, so the UI can tell
22
+ * when the current inputs have drifted from the last run (results are stale and
23
+ * "Run" should be pressed). The query is trimmed so trailing whitespace alone
24
+ * is not treated as a change.
25
+ */
26
+ export function searchInputsKey(inputs: SearchInputs): string {
27
+ return JSON.stringify([
28
+ inputs.mode,
29
+ inputs.query.trim(),
30
+ inputs.caseSensitive,
31
+ inputs.regex,
32
+ inputs.symbolAction,
33
+ inputs.side,
34
+ ]);
35
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Last-wins race-guard primitives for asynchronous lookups.
3
+ *
4
+ * Every run issues a fresh, monotonically increasing token. When a request's
5
+ * results arrive, they are applied only if their token still matches the latest
6
+ * issued one; results from a request that has since been superseded are
7
+ * dropped. This makes overlapping/duplicated async runs safe — necessary under
8
+ * React.StrictMode, where effects and async work double-fire in development.
9
+ */
10
+
11
+ /** Issue the next request token after `current` (starts at 1). */
12
+ export function nextToken(current: number): number {
13
+ return current + 1;
14
+ }
15
+
16
+ /**
17
+ * Whether a result tagged with `resultToken` should be applied given that
18
+ * `latestToken` is the most recently issued request.
19
+ *
20
+ * @param resultToken - The token the completed request was tagged with.
21
+ * @param latestToken - The token of the most recent request that was issued.
22
+ */
23
+ export function isCurrent(resultToken: number, latestToken: number): boolean {
24
+ return resultToken === latestToken;
25
+ }
@@ -0,0 +1,13 @@
1
+ import type { SplitRow } from "@/daemon/splitView.ts";
2
+
3
+ /**
4
+ * Whether one side of a split view carries no content at all — i.e. every cell
5
+ * on that side is empty padding. True for the base side of an added file and
6
+ * the head side of a deleted file, so the modal can show a placeholder instead
7
+ * of a blank column. An empty rows list returns false (there is nothing to lay
8
+ * out yet, e.g. while loading).
9
+ */
10
+ export function splitSideIsEmpty(rows: SplitRow[], side: "left" | "right"): boolean {
11
+ if (rows.length === 0) return false;
12
+ return rows.every((r) => r[side].kind === "empty");
13
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Format an ISO commit timestamp as a compact `YYYY-MM-DD HH:MM` (UTC) string.
3
+ * Returns an empty string for invalid input.
4
+ */
5
+ export function formatCommitTime(iso: string): string {
6
+ const date = new Date(iso);
7
+ if (Number.isNaN(date.getTime())) return "";
8
+ return date.toISOString().slice(0, 16).replace("T", " ");
9
+ }
@@ -0,0 +1,81 @@
1
+ import { useState } from "react";
2
+ import type { VisibilityToggles } from "./visibleFiles.ts";
3
+
4
+ /** Default visibility toggles: everything shown (all off). */
5
+ export const DEFAULT_TOGGLES: VisibilityToggles = {
6
+ hideViewedHunks: false,
7
+ hideViewedFiles: false,
8
+ hideLockFiles: false,
9
+ };
10
+
11
+ /**
12
+ * The subset of the browser `Storage` API this module needs. Declared as an
13
+ * interface so tests can supply an in-memory store; `window.localStorage`
14
+ * satisfies it structurally.
15
+ */
16
+ export interface ToggleStore {
17
+ /** Read a stored string value, or `null` if absent. */
18
+ getItem(key: string): string | null;
19
+ /** Persist a string value. */
20
+ setItem(key: string, value: string): void;
21
+ }
22
+
23
+ /** Local-storage key holding the toggle state for one PR. */
24
+ export function toggleStorageKey(prId: string): string {
25
+ return `mergie:toggles:${prId}`;
26
+ }
27
+
28
+ /**
29
+ * Local-storage key holding the per-PR "hide whitespace-only changes" flag.
30
+ * Separate from {@link toggleStorageKey} because it drives what the server
31
+ * diffs (an `--ignore-all-space` re-diff), not client-side file visibility.
32
+ */
33
+ export function hideWhitespaceStorageKey(prId: string): string {
34
+ return `mergie:hideWhitespace:${prId}`;
35
+ }
36
+
37
+ /**
38
+ * Read the persisted toggles for a PR, coercing each known key to a boolean
39
+ * against {@link DEFAULT_TOGGLES}. Returns the defaults when nothing is stored
40
+ * or the stored value is malformed.
41
+ */
42
+ export function readToggles(store: ToggleStore, prId: string): VisibilityToggles {
43
+ const raw: string | null = store.getItem(toggleStorageKey(prId));
44
+ if (raw === null) return { ...DEFAULT_TOGGLES };
45
+ const parsed: Record<string, unknown> | null = safeParse(raw);
46
+ if (parsed === null) return { ...DEFAULT_TOGGLES };
47
+ return {
48
+ hideViewedHunks: parsed.hideViewedHunks === true,
49
+ hideViewedFiles: parsed.hideViewedFiles === true,
50
+ hideLockFiles: parsed.hideLockFiles === true,
51
+ };
52
+ }
53
+
54
+ /** Persist the toggles for a PR. */
55
+ export function writeToggles(store: ToggleStore, prId: string, toggles: VisibilityToggles): void {
56
+ store.setItem(toggleStorageKey(prId), JSON.stringify(toggles));
57
+ }
58
+
59
+ /**
60
+ * React hook: toggle state that persists per-PR in local storage, so it
61
+ * survives full-page navigation (e.g. to the AI-reviews view) and browser
62
+ * restarts.
63
+ */
64
+ export function usePersistedToggles(prId: string): [VisibilityToggles, (t: VisibilityToggles) => void] {
65
+ const [toggles, setToggles] = useState<VisibilityToggles>(() => readToggles(window.localStorage, prId));
66
+ const update = (next: VisibilityToggles): void => {
67
+ writeToggles(window.localStorage, prId, next);
68
+ setToggles(next);
69
+ };
70
+ return [toggles, update];
71
+ }
72
+
73
+ /** Parse JSON into a plain object, or `null` on any failure / non-object. */
74
+ function safeParse(raw: string): Record<string, unknown> | null {
75
+ try {
76
+ const value: unknown = JSON.parse(raw);
77
+ return typeof value === "object" && value !== null ? { ...value } : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Close an overlay (modal/dialog) when the user presses Escape. Registers a
5
+ * window-level keydown listener while mounted and invokes `onClose` on Escape.
6
+ * `onClose` should be stable (e.g. from useState's setter or a memoised
7
+ * callback) so the listener is not re-registered on every render.
8
+ */
9
+ export function useEscToClose(onClose: () => void): void {
10
+ useEffect(() => {
11
+ const onKey = (e: KeyboardEvent): void => {
12
+ if (e.key === "Escape") onClose();
13
+ };
14
+ window.addEventListener("keydown", onKey);
15
+ return () => window.removeEventListener("keydown", onKey);
16
+ }, [onClose]);
17
+ }
@@ -0,0 +1,77 @@
1
+ import { useCallback, useState } from "react";
2
+ import { isIdentifier, fileFromSectionId, parseDataSide } from "./identifierMenu.ts";
3
+ import type { SearchSide } from "@/web/state/useCodeSearch.ts";
4
+
5
+ /** A pending symbol-lookup menu anchored at a viewport position. */
6
+ export interface IdentifierMenu {
7
+ /** The selected identifier term to look up. */
8
+ term: string;
9
+ /** Viewport x for the menu's left edge. */
10
+ x: number;
11
+ /** Viewport y for the menu's top edge. */
12
+ y: number;
13
+ /** Repo-relative path of the `.file-section` the selection is in (scope hint), or "". */
14
+ file: string;
15
+ /**
16
+ * The checkout side the selection sits on (from the code cell's `data-side`):
17
+ * a deleted/base line → "base", an added/context/head line → "head". Used as
18
+ * the menu's default side.
19
+ */
20
+ side: SearchSide;
21
+ }
22
+
23
+ /** The identifier-menu trigger: current menu + handlers to attach to a container. */
24
+ export interface UseIdentifierMenu {
25
+ /** The open menu descriptor, or null when nothing is selected. */
26
+ menu: IdentifierMenu | null;
27
+ /** Attach to a code container's `onMouseUp` (drag-select / primary click). */
28
+ onMouseUp: () => void;
29
+ /** Attach to a code container's `onDoubleClick` (word select). */
30
+ onDoubleClick: () => void;
31
+ /** Dismiss the menu. */
32
+ close: () => void;
33
+ }
34
+
35
+ /** The closest ancestor element of a DOM node (the node itself if it is one). */
36
+ function elementFor(node: Node | null): Element | null {
37
+ return node instanceof Element ? node : node?.parentElement ?? null;
38
+ }
39
+
40
+ /** The repo-relative file of the section containing a DOM node, or "". */
41
+ function fileForNode(node: Node | null): string {
42
+ const section = elementFor(node)?.closest(".file-section");
43
+ return fileFromSectionId(section?.id ?? "");
44
+ }
45
+
46
+ /** The checkout side of the code cell containing a DOM node (defaults to head). */
47
+ function sideForNode(node: Node | null): SearchSide {
48
+ const cell = elementFor(node)?.closest("[data-side]");
49
+ return parseDataSide(cell?.getAttribute("data-side") ?? null);
50
+ }
51
+
52
+ /** Read the current single-identifier text selection, if any, with its rect. */
53
+ function readSelection(): IdentifierMenu | null {
54
+ const sel = window.getSelection();
55
+ const term: string = sel?.toString().trim() ?? "";
56
+ if (!sel || sel.rangeCount === 0 || !isIdentifier(term)) return null;
57
+ const range = sel.getRangeAt(0);
58
+ const rect = range.getBoundingClientRect();
59
+ return {
60
+ term, x: rect.left, y: rect.bottom + 4,
61
+ file: fileForNode(range.startContainer), side: sideForNode(range.startContainer),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The reusable double-click / drag-select identifier trigger, attachable to any
67
+ * code container. On mouse-up or double-click it reads the current text
68
+ * selection and, when it is a single valid identifier, exposes a `menu`
69
+ * descriptor (term + viewport position + enclosing file scope). The caller
70
+ * renders the floating menu (portaled above modals) and clears it via `close`.
71
+ */
72
+ export function useIdentifierMenu(): UseIdentifierMenu {
73
+ const [menu, setMenu] = useState<IdentifierMenu | null>(null);
74
+ const update = useCallback((): void => setMenu(readSelection()), []);
75
+ const close = useCallback((): void => setMenu(null), []);
76
+ return { menu, onMouseUp: update, onDoubleClick: update, close };
77
+ }
@@ -0,0 +1,74 @@
1
+ import { useRef, useState } from "react";
2
+ import { trpc } from "@/web/trpc.ts";
3
+ import { nextToken, isCurrent } from "@/web/lib/searchToken.ts";
4
+ import { fetchResults, errorMessage } from "@/web/lib/codeSearchFetch.ts";
5
+ import { frameForLookup } from "@/web/lib/navRouting.ts";
6
+ import type { MenuOp, SearchSide } from "@/web/state/useCodeSearch.ts";
7
+ import type { NavFrame } from "@/web/lib/navStack.ts";
8
+
9
+ /** A lookup initiated from a double-click inside a navigator frame. */
10
+ export interface NavLookupArgs {
11
+ /** Which lookup to run. */
12
+ op: MenuOp;
13
+ /** The identifier to look up. */
14
+ term: string;
15
+ /** The checkout side to run against. */
16
+ side: SearchSide;
17
+ /** File scope hint (the enclosing file of the selection), or "". */
18
+ file: string;
19
+ /** The SHA to run against (resolved for the chosen side). */
20
+ sha: string;
21
+ }
22
+
23
+ /** The navigator lookup runner: loading/error state + a run action. */
24
+ export interface UseNavLookup {
25
+ /** True while a lookup is running (show a loading overlay). */
26
+ loading: boolean;
27
+ /** A user-facing error from the last lookup, or null. */
28
+ error: string | null;
29
+ /** Run a lookup and, on success, push the resolved frame. */
30
+ run: (args: NavLookupArgs) => void;
31
+ }
32
+
33
+ /**
34
+ * Runs symbol/search lookups for the navigator and pushes the resolved frame
35
+ * (via {@link frameForLookup}) when results arrive. Uses the shared
36
+ * {@link fetchResults} path and the {@link searchToken} last-wins race guard so
37
+ * overlapping runs — including StrictMode's double-fire — never push a stale
38
+ * frame. While a run is in flight `loading` is true so the caller can show a
39
+ * loading state instead of a blank frame (also covers the cold-clone wait).
40
+ *
41
+ * @param prId - PR id the lookups run against.
42
+ * @param push - Pushes a frame onto the navigator history.
43
+ */
44
+ export function useNavLookup(prId: string, push: (frame: NavFrame) => void): UseNavLookup {
45
+ const utils = trpc.useUtils();
46
+ const token = useRef(0);
47
+ const [loading, setLoading] = useState(false);
48
+ const [error, setError] = useState<string | null>(null);
49
+
50
+ const run = (args: NavLookupArgs): void => {
51
+ const id = nextToken(token.current);
52
+ token.current = id;
53
+ setLoading(true);
54
+ setError(null);
55
+ if (args.sha === "" || args.term === "") { setLoading(false); return; }
56
+ void (async () => {
57
+ try {
58
+ const out = await fetchResults(utils, prId, args.sha, {
59
+ mode: args.op === "search" ? "general" : "symbol",
60
+ symbolAction: args.op === "usages" ? "usages" : "definition",
61
+ term: args.term, side: args.side, caseSensitive: false, regex: false,
62
+ file: args.file === "" ? undefined : args.file,
63
+ });
64
+ if (!isCurrent(id, token.current)) return;
65
+ setLoading(false);
66
+ push(frameForLookup(args.op, args.term, args.side, args.sha, out, args.file));
67
+ } catch (err) {
68
+ if (isCurrent(id, token.current)) { setError(errorMessage(err)); setLoading(false); }
69
+ }
70
+ })();
71
+ };
72
+
73
+ return { loading, error, run };
74
+ }
@@ -0,0 +1,15 @@
1
+ import { useEffect } from "react";
2
+
3
+ /** Base title used when no PR-specific title is set. */
4
+ const BASE_TITLE = "mergie";
5
+
6
+ /**
7
+ * Set the browser tab title, restoring the base title on unmount. A null/empty
8
+ * value leaves the base title in place (e.g. while data is still loading).
9
+ */
10
+ export function usePageTitle(title: string | null): void {
11
+ useEffect(() => {
12
+ document.title = title && title.length > 0 ? `${title} · ${BASE_TITLE}` : BASE_TITLE;
13
+ return () => { document.title = BASE_TITLE; };
14
+ }, [title]);
15
+ }
@@ -0,0 +1,52 @@
1
+ import { fuzzyFilter } from "@/domain/fuzzy.ts";
2
+ import type { FileView } from "@/daemon/reviewService.ts";
3
+
4
+ /** Which categories the visibility toggles hide. */
5
+ export interface VisibilityToggles {
6
+ /** Hide hunks already marked viewed. */
7
+ hideViewedHunks: boolean;
8
+ /** Hide files that are fully viewed. */
9
+ hideViewedFiles: boolean;
10
+ /** Hide lock/generated files. */
11
+ hideLockFiles: boolean;
12
+ }
13
+
14
+ /**
15
+ * Compute the files to render given the fuzzy search query and the visibility
16
+ * toggles. Pure — does not mutate its inputs.
17
+ *
18
+ * @param files The full file list for the range.
19
+ * @param query Fuzzy search text over file paths (empty = no filter).
20
+ * @param toggles Active visibility toggles.
21
+ * @param reveal Hunk hashes to always show even when a toggle would hide them
22
+ * (used to jump to a comment on a toggled-away hunk without
23
+ * flipping the toggle). Defaults to none.
24
+ */
25
+ export function visibleFiles(
26
+ files: readonly FileView[],
27
+ query: string,
28
+ toggles: VisibilityToggles,
29
+ reveal: ReadonlySet<string> = new Set(),
30
+ ): FileView[] {
31
+ const ordered: FileView[] = applyQuery(files, query);
32
+ const result: FileView[] = [];
33
+ for (const file of ordered) {
34
+ const fileRevealed: boolean = file.hunks.some((h) => reveal.has(h.hash));
35
+ if (toggles.hideLockFiles && file.isLockfile && !fileRevealed) continue;
36
+ if (toggles.hideViewedFiles && file.viewed && !fileRevealed) continue;
37
+ const hunks = toggles.hideViewedHunks
38
+ ? file.hunks.filter((h) => !h.viewed || reveal.has(h.hash))
39
+ : file.hunks;
40
+ if (toggles.hideViewedHunks && hunks.length === 0) continue;
41
+ result.push({ ...file, hunks });
42
+ }
43
+ return result;
44
+ }
45
+
46
+ /** Order files by fuzzy match when a query is present; otherwise keep order. */
47
+ function applyQuery(files: readonly FileView[], query: string): FileView[] {
48
+ if (query.length === 0) return [...files];
49
+ const ranked: string[] = fuzzyFilter(query, files.map((f) => f.newPath));
50
+ const byPath = new Map(files.map((f) => [f.newPath, f]));
51
+ return ranked.map((path) => byPath.get(path)).filter((f): f is FileView => f !== undefined);
52
+ }
@@ -0,0 +1,24 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { httpBatchLink } from "@trpc/client";
5
+ import { trpc } from "./trpc.ts";
6
+ import { App } from "./App.tsx";
7
+ import "highlight.js/styles/github.css";
8
+ import "./styles.css";
9
+
10
+ const rootElement: HTMLElement | null = document.getElementById("root");
11
+ if (!rootElement) throw new Error("Root element #root not found");
12
+
13
+ const queryClient = new QueryClient();
14
+ const trpcClient = trpc.createClient({ links: [httpBatchLink({ url: "/trpc" })] });
15
+
16
+ createRoot(rootElement).render(
17
+ <StrictMode>
18
+ <trpc.Provider client={trpcClient} queryClient={queryClient}>
19
+ <QueryClientProvider client={queryClient}>
20
+ <App />
21
+ </QueryClientProvider>
22
+ </trpc.Provider>
23
+ </StrictMode>,
24
+ );
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="mergie">
2
+ <rect width="32" height="32" rx="7" fill="#ffffff"/>
3
+ <!-- added line: green "+" and bar -->
4
+ <line x1="8" y1="8" x2="8" y2="12" stroke="#2da44e" stroke-width="2" stroke-linecap="round"/>
5
+ <line x1="6" y1="10" x2="10" y2="10" stroke="#2da44e" stroke-width="2" stroke-linecap="round"/>
6
+ <rect x="13" y="8.5" width="13" height="3" rx="1.5" fill="#2da44e"/>
7
+ <!-- removed line: red "−" and bar -->
8
+ <line x1="6" y1="16" x2="10" y2="16" stroke="#cf222e" stroke-width="2" stroke-linecap="round"/>
9
+ <rect x="13" y="14.5" width="10" height="3" rx="1.5" fill="#cf222e"/>
10
+ <!-- context line -->
11
+ <rect x="13" y="20.5" width="8" height="3" rx="1.5" fill="#c8d1da"/>
12
+ </svg>