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,36 @@
1
+ import type { AllCommentEntry } from "@/daemon/allComments.ts";
2
+
3
+ /** Author filter: everyone, only mine, or only other people's. */
4
+ export type AuthorFilter = "all" | "mine" | "others";
5
+
6
+ /**
7
+ * Source filter for the All-comments view. A comment posted from mergie is a
8
+ * GitHub comment, so it falls under `github` (never `local`); `local` is only
9
+ * never-posted drafts.
10
+ */
11
+ export type SourceFilter = "all" | "local" | "github";
12
+
13
+ /** The active filters on the All-comments view. */
14
+ export interface CommentFilters {
15
+ /** Filter by authorship. */
16
+ author: AuthorFilter;
17
+ /** Filter by origin. */
18
+ source: SourceFilter;
19
+ /** File path to restrict to; empty string means all files. */
20
+ file: string;
21
+ }
22
+
23
+ /**
24
+ * Filter unified comment entries by author, source, and file. Pure — returns a
25
+ * new array and never mutates its input.
26
+ */
27
+ export function filterAllComments(entries: AllCommentEntry[], filters: CommentFilters): AllCommentEntry[] {
28
+ return entries.filter((e) => {
29
+ if (filters.author === "mine" && !e.mine) return false;
30
+ if (filters.author === "others" && e.mine) return false;
31
+ if (filters.source === "local" && e.origin !== "local") return false;
32
+ if (filters.source === "github" && e.origin === "local") return false;
33
+ if (filters.file !== "" && e.path !== filters.file) return false;
34
+ return true;
35
+ });
36
+ }
@@ -0,0 +1,90 @@
1
+ import type { AllCommentEntry } from "@/daemon/allComments.ts";
2
+ import type { FileView } from "@/daemon/reviewService.ts";
3
+
4
+ /** DOM id for a rendered local (mergie) comment element. */
5
+ export function localCommentDomId(localId: number): string {
6
+ return `comment-local-${localId}`;
7
+ }
8
+
9
+ /** DOM id for a rendered synced GitHub thread element. */
10
+ export function githubCommentDomId(githubId: string): string {
11
+ return `comment-gh-${githubId}`;
12
+ }
13
+
14
+ /**
15
+ * Ordered element ids to try scrolling to for an entry. A GitHub-backed comment
16
+ * usually renders as its synced thread (`comment-gh-…`), but a just-posted one
17
+ * that has not been fetched back yet still renders as a local element
18
+ * (`comment-local-…`) — so posted entries list both, thread first.
19
+ */
20
+ export function commentDomIdCandidates(entry: AllCommentEntry): string[] {
21
+ const ids: string[] = [];
22
+ if (entry.githubId !== null) ids.push(githubCommentDomId(entry.githubId));
23
+ if (entry.localId !== null) ids.push(localCommentDomId(entry.localId));
24
+ return ids;
25
+ }
26
+
27
+ /**
28
+ * Whether an All-comments entry is actually rendered in the current diff range.
29
+ *
30
+ * A comment is "visible in the diff" only when the range view has anchored it to
31
+ * a hunk — the same strict anchoring the diff uses (exact line/hunk present).
32
+ * Local drafts and mergie-posted comments match by their local comment id;
33
+ * GitHub-origin comments (and the posted comment's synced thread) match by the
34
+ * thread's root GitHub id. If it matches nothing rendered, honoring a click on
35
+ * it would require changing the range, so it is treated as out-of-range.
36
+ *
37
+ * Pure — reads only its arguments, mutates nothing.
38
+ *
39
+ * @param entry The unified comment entry the user clicked.
40
+ * @param files The files/hunks currently rendered for the selected range.
41
+ */
42
+ export function commentVisibleInDiff(entry: AllCommentEntry, files: FileView[]): boolean {
43
+ return commentHunkHash(entry, files) !== null;
44
+ }
45
+
46
+ /**
47
+ * The content hash of the hunk that anchors this comment within `files`, or
48
+ * `null` if the comment is not anchored anywhere in them. Same matching as
49
+ * {@link commentVisibleInDiff} (local id or synced-thread id). Pure.
50
+ */
51
+ export function commentHunkHash(entry: AllCommentEntry, files: FileView[]): string | null {
52
+ for (const file of files) {
53
+ for (const hunk of file.hunks) {
54
+ if (entry.localId !== null && hunk.comments.some((c) => c.id === entry.localId)) return hunk.hash;
55
+ if (entry.githubId !== null && hunk.githubThreads.some((t) => t.root.githubId === entry.githubId)) return hunk.hash;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * What clicking a comment row in the All-comments panel should do, given the
63
+ * files for the whole selected range (`rangeFiles`) and the subset currently
64
+ * rendered after view toggles (`renderedFiles`):
65
+ * - `scroll` — it's already on screen; just scroll to it.
66
+ * - `reveal` — it belongs to the current range but a view toggle (or an
67
+ * auto-collapsed viewed hunk) is hiding it; temporarily reveal that hunk,
68
+ * then scroll. Carries the hunk hash to reveal. The selected range is never
69
+ * changed.
70
+ * - `out-of-range` — it isn't in the current range at all; honouring it would
71
+ * require changing the range, so the caller offers to open it in a new tab.
72
+ *
73
+ * Pure — reads only its arguments.
74
+ */
75
+ export type CommentClickAction =
76
+ | { kind: "scroll" }
77
+ | { kind: "reveal"; hunkHash: string }
78
+ | { kind: "out-of-range" };
79
+
80
+ /** Classify a comment-row click into a {@link CommentClickAction}. */
81
+ export function classifyCommentClick(
82
+ entry: AllCommentEntry,
83
+ rangeFiles: FileView[],
84
+ renderedFiles: FileView[],
85
+ ): CommentClickAction {
86
+ if (commentVisibleInDiff(entry, renderedFiles)) return { kind: "scroll" };
87
+ const hunkHash: string | null = commentHunkHash(entry, rangeFiles);
88
+ if (hunkHash !== null) return { kind: "reveal", hunkHash };
89
+ return { kind: "out-of-range" };
90
+ }
@@ -0,0 +1,24 @@
1
+ /** The subset of a keyboard event the composer cares about. */
2
+ export interface ComposerKey {
3
+ /** `KeyboardEvent.key` (e.g. "Enter", "Escape"). */
4
+ key: string;
5
+ /** Whether the Command key (macOS) is held. */
6
+ metaKey: boolean;
7
+ /** Whether the Control key is held. */
8
+ ctrlKey: boolean;
9
+ }
10
+
11
+ /** What a keypress in the composer should do. */
12
+ export type ComposerIntent = "submit" | "cancel" | null;
13
+
14
+ /**
15
+ * Map a keydown in a comment composer to an action. Cmd/Ctrl+Enter submits
16
+ * (plain Enter stays a newline, as expected in a multi-line body); Escape
17
+ * cancels. Anything else is `null` (let the textarea handle it). Pure — reads
18
+ * only the passed fields, mutates nothing.
19
+ */
20
+ export function composerKeyIntent(event: ComposerKey): ComposerIntent {
21
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) return "submit";
22
+ if (event.key === "Escape") return "cancel";
23
+ return null;
24
+ }
@@ -0,0 +1,37 @@
1
+ import type { CodeResult } from "@/services/symbols.ts";
2
+
3
+ /** Combine two optional scope labels into a comma-separated, de-duplicated string. */
4
+ function mergeScopes(a: string | undefined, b: string | undefined): string | undefined {
5
+ const parts: string[] = [];
6
+ for (const s of [a, b]) {
7
+ if (s !== undefined && s !== "" && !parts.includes(s)) parts.push(s);
8
+ }
9
+ return parts.length === 0 ? undefined : parts.join(", ");
10
+ }
11
+
12
+ /**
13
+ * Merge results that share the same `(path, line)` into a single item, joining
14
+ * their distinct `scope` labels. The same physical line can be reported more
15
+ * than once — e.g. a usage that falls inside both a class and one of its
16
+ * methods — and should appear once with the scopes combined.
17
+ *
18
+ * Preserves first-seen order and never mutates the input array or its items.
19
+ *
20
+ * @param results - The raw result list (possibly containing duplicate lines).
21
+ */
22
+ export function dedupeResults(results: readonly CodeResult[]): CodeResult[] {
23
+ const byKey = new Map<string, CodeResult>();
24
+ const out: CodeResult[] = [];
25
+ for (const r of results) {
26
+ const key = `${r.path}:${r.line}`;
27
+ const existing = byKey.get(key);
28
+ if (existing === undefined) {
29
+ const copy: CodeResult = { ...r };
30
+ byKey.set(key, copy);
31
+ out.push(copy);
32
+ } else {
33
+ existing.scope = mergeScopes(existing.scope, r.scope);
34
+ }
35
+ }
36
+ return out;
37
+ }
@@ -0,0 +1,81 @@
1
+ import type { CharRange } from "@/domain/diff.ts";
2
+
3
+ /** Opening tag for a highlighted changed run. */
4
+ const MARK_OPEN = '<mark class="diff-word">';
5
+ /** Closing tag for a highlighted changed run. */
6
+ const MARK_CLOSE = "</mark>";
7
+
8
+ /** A unit of already-highlighted HTML: a tag, an entity, or a literal char. */
9
+ interface Token {
10
+ /** The raw HTML slice. */
11
+ html: string;
12
+ /** How many plain-text characters this token represents (0 for tags). */
13
+ width: number;
14
+ }
15
+
16
+ /**
17
+ * Overlay word-diff highlighting onto already-syntax-highlighted HTML: wrap the
18
+ * plain-text character ranges in `<mark class="diff-word">`, splitting so the
19
+ * mark never crosses a syntax-highlight tag boundary (it is closed before every
20
+ * tag and reopened after, keeping the markup well-nested). Pure — HTML in, HTML
21
+ * out.
22
+ *
23
+ * @param html - One line of syntax-highlighted HTML (from `highlightToHtml`).
24
+ * @param ranges - Changed character ranges as offsets into the *plain* text.
25
+ */
26
+ export function applyDiffMarks(html: string, ranges: CharRange[]): string {
27
+ if (ranges.length === 0) return html;
28
+ let out = "";
29
+ let offset = 0;
30
+ let markOpen = false;
31
+ for (const token of tokenize(html)) {
32
+ if (token.width === 0) {
33
+ if (markOpen) { out += MARK_CLOSE; markOpen = false; }
34
+ out += token.html;
35
+ continue;
36
+ }
37
+ const inRange = isInRange(offset, ranges);
38
+ if (inRange && !markOpen) { out += MARK_OPEN; markOpen = true; }
39
+ else if (!inRange && markOpen) { out += MARK_CLOSE; markOpen = false; }
40
+ out += token.html;
41
+ offset += token.width;
42
+ }
43
+ if (markOpen) out += MARK_CLOSE;
44
+ return out;
45
+ }
46
+
47
+ /** Split highlighted HTML into tags (width 0), entities (width 1), and chars. */
48
+ function tokenize(html: string): Token[] {
49
+ const tokens: Token[] = [];
50
+ let i = 0;
51
+ while (i < html.length) {
52
+ const ch = html[i];
53
+ if (ch === "<") {
54
+ const end = html.indexOf(">", i);
55
+ const stop = end === -1 ? html.length : end + 1;
56
+ tokens.push({ html: html.slice(i, stop), width: 0 });
57
+ i = stop;
58
+ } else if (ch === "&") {
59
+ const entity = matchEntity(html, i);
60
+ tokens.push({ html: entity, width: 1 });
61
+ i += entity.length;
62
+ } else {
63
+ tokens.push({ html: ch ?? "", width: 1 });
64
+ i += 1;
65
+ }
66
+ }
67
+ return tokens;
68
+ }
69
+
70
+ /** Match an HTML entity starting at `i`, or the bare `&` if it is not one. */
71
+ function matchEntity(html: string, i: number): string {
72
+ const semi = html.indexOf(";", i);
73
+ if (semi === -1) return "&";
74
+ const body = html.slice(i + 1, semi);
75
+ return /^#?[a-zA-Z0-9]+$/.test(body) ? html.slice(i, semi + 1) : "&";
76
+ }
77
+
78
+ /** Whether a plain-text offset falls within any changed range. */
79
+ function isInRange(offset: number, ranges: CharRange[]): boolean {
80
+ return ranges.some((r) => offset >= r.start && offset < r.end);
81
+ }
@@ -0,0 +1,12 @@
1
+ import type { FileStatus } from "@/domain/diff.ts";
2
+
3
+ /**
4
+ * The visual variant class for a file's diff status, used to colour the file
5
+ * tree's status icon. `renamed` shares the neutral "modified" treatment since a
6
+ * rename is a content change rather than an add/remove.
7
+ */
8
+ export function fileStatusClass(status: FileStatus): "added" | "deleted" | "modified" {
9
+ if (status === "added") return "added";
10
+ if (status === "deleted") return "deleted";
11
+ return "modified";
12
+ }
@@ -0,0 +1,42 @@
1
+ import type { CodeResult } from "@/services/symbols.ts";
2
+
3
+ /** Client-side filter options for a list of code results. */
4
+ export interface CodeResultFilters {
5
+ /** Case-insensitive substring the file path must contain. */
6
+ pathText?: string;
7
+ /**
8
+ * Case-insensitive substring the code must contain — matched against the
9
+ * matched line, the definition body, and the before/after context lines.
10
+ */
11
+ codeText?: string;
12
+ /** When true, drop results the backend flagged as test/generated. */
13
+ excludeTestsGenerated?: boolean;
14
+ }
15
+
16
+ /** Whether any of a result's code text contains `needle` (case-insensitive). */
17
+ function codeContains(r: CodeResult, needle: string): boolean {
18
+ const haystacks: string[] = [r.matched, r.body ?? "", ...r.before, ...r.after];
19
+ return haystacks.some((h) => h.toLowerCase().includes(needle));
20
+ }
21
+
22
+ /**
23
+ * Filter code results by path substring, code substring, and an
24
+ * exclude-tests/generated toggle. All active filters combine with AND.
25
+ * Purely derives a new array; never mutates the input.
26
+ *
27
+ * @param results - The full result list.
28
+ * @param filters - The active filter options (any subset).
29
+ */
30
+ export function filterCodeResults(
31
+ results: readonly CodeResult[],
32
+ filters: CodeResultFilters,
33
+ ): CodeResult[] {
34
+ const pathNeedle = filters.pathText?.toLowerCase() ?? "";
35
+ const codeNeedle = filters.codeText?.toLowerCase() ?? "";
36
+ return results.filter((r) => {
37
+ if (filters.excludeTestsGenerated === true && r.testOrGenerated) return false;
38
+ if (pathNeedle !== "" && !r.path.toLowerCase().includes(pathNeedle)) return false;
39
+ if (codeNeedle !== "" && !codeContains(r, codeNeedle)) return false;
40
+ return true;
41
+ });
42
+ }
@@ -0,0 +1,38 @@
1
+ /** The minimal PR shape the picker filter needs. */
2
+ export interface FilterablePr {
3
+ /** Repository owner / organisation. */
4
+ owner: string;
5
+ /** Repository name. */
6
+ repo: string;
7
+ /** Pull request number. */
8
+ number: number;
9
+ /** PR title. */
10
+ title: string;
11
+ /** GitHub login of the author (absent for already-loaded PRs). */
12
+ author?: string;
13
+ }
14
+
15
+ /**
16
+ * Case-insensitively filter PRs by a free-text query. A PR matches when the
17
+ * query (trimmed) is a substring of any of: `owner`, `repo`, `owner/repo`, the
18
+ * title, the author, or the number (with or without a leading `#`). A blank
19
+ * query returns every PR. The input array is never mutated.
20
+ */
21
+ export function filterPrs<T extends FilterablePr>(query: string, prs: readonly T[]): T[] {
22
+ const q: string = query.trim().toLowerCase();
23
+ if (q.length === 0) return [...prs];
24
+ return prs.filter((pr) => haystack(pr).some((field) => field.includes(q)));
25
+ }
26
+
27
+ /** All lowercased fields of a PR that the query is tested against. */
28
+ function haystack(pr: FilterablePr): string[] {
29
+ return [
30
+ pr.owner,
31
+ pr.repo,
32
+ `${pr.owner}/${pr.repo}`,
33
+ pr.title,
34
+ pr.author ?? "",
35
+ `#${pr.number}`,
36
+ String(pr.number),
37
+ ].map((f) => f.toLowerCase());
38
+ }
@@ -0,0 +1,40 @@
1
+ import hljs from "highlight.js";
2
+
3
+ /** File-extension → highlight.js language name. */
4
+ const EXT_LANG: Record<string, string> = {
5
+ ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
6
+ js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
7
+ json: "json", css: "css", scss: "scss", html: "xml", xml: "xml",
8
+ md: "markdown", py: "python", rs: "rust", go: "go", sh: "bash", bash: "bash",
9
+ yml: "yaml", yaml: "yaml", sql: "sql", java: "java", rb: "ruby", php: "php",
10
+ };
11
+
12
+ /** The highlight.js language for a path, or undefined if unknown. */
13
+ export function languageForPath(path: string): string | undefined {
14
+ const dot: number = path.lastIndexOf(".");
15
+ if (dot === -1) return undefined;
16
+ return EXT_LANG[path.slice(dot + 1).toLowerCase()];
17
+ }
18
+
19
+ /** Escape HTML special characters. */
20
+ function escapeHtml(text: string): string {
21
+ return text
22
+ .replaceAll("&", "&amp;")
23
+ .replaceAll("<", "&lt;")
24
+ .replaceAll(">", "&gt;");
25
+ }
26
+
27
+ /**
28
+ * Return highlighted HTML for a line of code. Falls back to escaped plain text
29
+ * when the language is unknown or highlighting fails.
30
+ */
31
+ export function highlightToHtml(code: string, language: string | undefined): string {
32
+ if (language && hljs.getLanguage(language)) {
33
+ try {
34
+ return hljs.highlight(code, { language }).value;
35
+ } catch {
36
+ return escapeHtml(code);
37
+ }
38
+ }
39
+ return escapeHtml(code);
40
+ }
@@ -0,0 +1,41 @@
1
+ import type { DiffLine } from "@/domain/diff.ts";
2
+ import type { SearchSide } from "@/web/state/useCodeSearch.ts";
3
+
4
+ /** Regex matching a single lookup-able identifier token (JS-like). */
5
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
6
+
7
+ /**
8
+ * Whether `term` is a single valid identifier we can look up (no dots, calls,
9
+ * spaces, or leading digits). The menu only opens for such tokens.
10
+ */
11
+ export function isIdentifier(term: string): boolean {
12
+ return IDENTIFIER.test(term);
13
+ }
14
+
15
+ /**
16
+ * The repo-relative file path encoded in a `.file-section` element id
17
+ * (`file-<path>`), or "" when the id is not a file section. Only the leading
18
+ * `file-` prefix is stripped, so paths that themselves contain `file-` survive.
19
+ */
20
+ export function fileFromSectionId(sectionId: string): string {
21
+ const prefix = "file-";
22
+ return sectionId.startsWith(prefix) ? sectionId.slice(prefix.length) : "";
23
+ }
24
+
25
+ /**
26
+ * Which checkout a diff line belongs to: a deleted line only exists on the base,
27
+ * so a symbol selected there defaults the lookup to base; added and context
28
+ * lines default to head (context is identical on both, head is the useful one).
29
+ */
30
+ export function sideForLineKind(kind: DiffLine["kind"]): SearchSide {
31
+ return kind === "del" ? "base" : "head";
32
+ }
33
+
34
+ /**
35
+ * Interpret a `data-side` attribute stamped on a code cell. Only the explicit
36
+ * value "base" selects base; anything else (including a missing attribute)
37
+ * defaults to head.
38
+ */
39
+ export function parseDataSide(value: string | null): SearchSide {
40
+ return value === "base" ? "base" : "head";
41
+ }
@@ -0,0 +1,48 @@
1
+ import type { DiffLine } from "@/domain/diff.ts";
2
+ import type { DiffSide } from "@/domain/hash.ts";
3
+
4
+ /** The anchor data for a (possibly multi-line) comment selection. */
5
+ export interface LinesAnchor {
6
+ /** Which side the comment is on. */
7
+ side: DiffSide;
8
+ /** Exact head/base text of the selected same-side lines, newline-joined. */
9
+ lineText: string;
10
+ /** First selected line number on the chosen side. */
11
+ lineNo: number;
12
+ /** Last selected line number on the chosen side. */
13
+ endLineNo: number;
14
+ }
15
+
16
+ /** Whether a line participates on a given side. */
17
+ function onSide(line: DiffLine, side: DiffSide): boolean {
18
+ return side === "RIGHT" ? line.kind === "add" || line.kind === "ctx" : line.kind === "del" || line.kind === "ctx";
19
+ }
20
+
21
+ /**
22
+ * Derive the comment anchor for a selected range of diff lines. Chooses the
23
+ * head (RIGHT) side unless the selection is deletions only, then joins the
24
+ * text of the selected lines that belong to that side.
25
+ *
26
+ * @returns The anchor, or null if the selection has no line on the chosen side.
27
+ */
28
+ export function buildLinesAnchor(lines: DiffLine[], startIdx: number, endIdx: number): LinesAnchor | null {
29
+ const a: number = Math.min(startIdx, endIdx);
30
+ const b: number = Math.max(startIdx, endIdx);
31
+ const selected: DiffLine[] = lines.slice(a, b + 1);
32
+ const hasAdd: boolean = selected.some((l) => l.kind === "add");
33
+ const hasDel: boolean = selected.some((l) => l.kind === "del");
34
+ const side: DiffSide = !hasAdd && hasDel ? "LEFT" : "RIGHT";
35
+
36
+ const sideLines: DiffLine[] = selected.filter((l) => onSide(l, side));
37
+ const first: DiffLine | undefined = sideLines[0];
38
+ const last: DiffLine | undefined = sideLines[sideLines.length - 1];
39
+ if (!first || !last) return null;
40
+
41
+ const num = (l: DiffLine): number => (side === "RIGHT" ? l.newNo : l.oldNo) ?? 0;
42
+ return {
43
+ side,
44
+ lineText: sideLines.map((l) => l.text).join("\n"),
45
+ lineNo: num(first),
46
+ endLineNo: num(last),
47
+ };
48
+ }
@@ -0,0 +1,40 @@
1
+ import type { CodeResult } from "@/services/symbols.ts";
2
+ import type { MenuOp, SearchSide } from "@/web/state/useCodeSearch.ts";
3
+ import type { NavFrame } from "./navStack.ts";
4
+
5
+ /** Human label for a lookup op, for the results-frame header. */
6
+ export function opLabel(op: MenuOp): string {
7
+ if (op === "search") return "Search";
8
+ return op === "usages" ? "Usages" : "Definition";
9
+ }
10
+
11
+ /**
12
+ * The frame to push after a lookup completes. An *unscoped* single-result
13
+ * Definition jumps straight to the definition (a `file` frame); everything else
14
+ * — Usages, Search, multi- or zero-result Definition, and any *scoped* lookup —
15
+ * shows the shared results list (a `results` frame, which renders the empty
16
+ * state and the scope chip + broaden action). A scoped single-result Definition
17
+ * deliberately stays a results frame so the "search everywhere" broaden control
18
+ * is reachable.
19
+ *
20
+ * @param op - The lookup op that ran.
21
+ * @param term - The looked-up term / query.
22
+ * @param side - The checkout side the lookup ran against.
23
+ * @param sha - The SHA the results were found at.
24
+ * @param results - The results returned.
25
+ * @param file - The file the lookup was scoped to ("" means repo-wide).
26
+ */
27
+ export function frameForLookup(
28
+ op: MenuOp,
29
+ term: string,
30
+ side: SearchSide,
31
+ sha: string,
32
+ results: CodeResult[],
33
+ file: string,
34
+ ): NavFrame {
35
+ if (op === "definition" && results.length === 1 && file === "") {
36
+ const r = results[0]!;
37
+ return { kind: "file", path: r.path, sha, line: r.line };
38
+ }
39
+ return { kind: "results", op, term, side, sha, scopeFile: file, results };
40
+ }
@@ -0,0 +1,109 @@
1
+ import type { CodeResult } from "@/services/symbols.ts";
2
+ import type { MenuOp, SearchSide } from "@/web/state/useCodeSearch.ts";
3
+
4
+ /** A diff frame: a file's split base/head view over the navigator's range. */
5
+ export interface DiffNavFrame {
6
+ /** Frame discriminant. */
7
+ kind: "diff";
8
+ /** Repo-relative file path. */
9
+ path: string;
10
+ /** 1-based line to center/flash, or null to open at the top. */
11
+ anchorLine?: number | null;
12
+ }
13
+
14
+ /** A file frame: a single-version file view at one commit, centered on a line. */
15
+ export interface FileNavFrame {
16
+ /** Frame discriminant. */
17
+ kind: "file";
18
+ /** Repo-relative file path. */
19
+ path: string;
20
+ /** Commit SHA to read the file at. */
21
+ sha: string;
22
+ /** 1-based line to center and flash. */
23
+ line: number;
24
+ }
25
+
26
+ /** A results frame: a list of code results from a lookup/search. */
27
+ export interface ResultsNavFrame {
28
+ /** Frame discriminant. */
29
+ kind: "results";
30
+ /** The lookup op that produced the results. */
31
+ op: MenuOp;
32
+ /** The looked-up term / query. */
33
+ term: string;
34
+ /** Which checkout side the lookup ran against. */
35
+ side: SearchSide;
36
+ /** The SHA the results were found at (for opening file frames). */
37
+ sha: string;
38
+ /** The file the lookup was scoped to (a "" means repo-wide). */
39
+ scopeFile: string;
40
+ /** The results to list. */
41
+ results: CodeResult[];
42
+ }
43
+
44
+ /** One frame in the navigator's Back/Forward history. */
45
+ export type NavFrame = DiffNavFrame | FileNavFrame | ResultsNavFrame;
46
+
47
+ /** The navigator's history: the frame list plus the current position in it. */
48
+ export interface NavStack {
49
+ /** The frames, oldest → newest. */
50
+ stack: NavFrame[];
51
+ /** Index of the currently shown frame. */
52
+ index: number;
53
+ }
54
+
55
+ /** An action the {@link navStackReducer} understands. */
56
+ export type NavStackAction =
57
+ | { type: "push"; frame: NavFrame }
58
+ | { type: "back" }
59
+ | { type: "forward" };
60
+
61
+ /** A fresh history seeded with a single origin frame. */
62
+ export function initNavStack(frame: NavFrame): NavStack {
63
+ return { stack: [frame], index: 0 };
64
+ }
65
+
66
+ /** The frame currently shown. */
67
+ export function currentFrame(s: NavStack): NavFrame {
68
+ return s.stack[s.index]!;
69
+ }
70
+
71
+ /**
72
+ * A stable identity key for a frame: frames that show the same content share a
73
+ * key. Used to keep each visited frame mounted (so its scroll position survives
74
+ * Back/Forward) while giving React a distinct key per history entry.
75
+ */
76
+ export function frameKey(frame: NavFrame): string {
77
+ if (frame.kind === "diff") return `diff:${frame.path}`;
78
+ if (frame.kind === "file") return `file:${frame.path}:${frame.sha}`;
79
+ return `results:${frame.op}:${frame.term}:${frame.side}:${frame.sha}`;
80
+ }
81
+
82
+ /** Whether there is an earlier frame to go Back to. */
83
+ export function canGoBack(s: NavStack): boolean {
84
+ return s.index > 0;
85
+ }
86
+
87
+ /** Whether there is a later frame to go Forward to. */
88
+ export function canGoForward(s: NavStack): boolean {
89
+ return s.index < s.stack.length - 1;
90
+ }
91
+
92
+ /**
93
+ * The navigator history reducer. `push` drops any forward history (frames after
94
+ * the current index) and appends the new frame as the current one; `back` /
95
+ * `forward` move the cursor, clamped to the ends. Never mutates its input.
96
+ */
97
+ export function navStackReducer(state: NavStack, action: NavStackAction): NavStack {
98
+ switch (action.type) {
99
+ case "push": {
100
+ const kept = state.stack.slice(0, state.index + 1);
101
+ const stack = [...kept, action.frame];
102
+ return { stack, index: stack.length - 1 };
103
+ }
104
+ case "back":
105
+ return { stack: state.stack, index: Math.max(0, state.index - 1) };
106
+ case "forward":
107
+ return { stack: state.stack, index: Math.min(state.stack.length - 1, state.index + 1) };
108
+ }
109
+ }